diff --git a/web/cashtab/src/assets/fingerprint-solid.svg b/web/cashtab/src/assets/fingerprint-solid.svg
new file mode 100644
index 000000000..65a12406d
--- /dev/null
+++ b/web/cashtab/src/assets/fingerprint-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/cashtab/src/assets/styles/theme.js b/web/cashtab/src/assets/styles/theme.js
index 8e770cb61..d1aa45447 100644
--- a/web/cashtab/src/assets/styles/theme.js
+++ b/web/cashtab/src/assets/styles/theme.js
@@ -1,80 +1,89 @@
export const theme = {
primary: '#00ABE7',
brandSecondary: '#CD0BC3',
contrast: '#fff',
app: {
sidebars: `url("/cashtab_bg.png")`,
background: '#fbfbfd',
},
wallet: {
background: '#fff',
text: {
primary: '#273498',
secondary: '#273498',
},
switch: {
activeCash: {
shadow: 'inset 8px 8px 16px #0074C2, inset -8px -8px 16px #273498',
},
activeToken: {
background: '#CD0BC3',
shadow: 'inset 5px 5px 11px #FF21D0, inset -5px -5px 11px #CD0BC3',
},
inactive: {
background: 'linear-gradient(145deg, #eeeeee, #c8c8c8)',
},
},
borders: { color: '#e2e2e2' },
shadow: 'rgba(0, 0, 0, 1)',
},
tokenListItem: {
background: '#ffffff',
color: '',
boxShadow:
'rgb(136 172 243 / 25%) 0px 10px 30px,rgb(0 0 0 / 3%) 0px 1px 1px, rgb(0 51 167 / 10%) 0px 10px 20px',
border: '#e9eaed',
hoverBorder: '#231F20',
},
footer: {
background: '#fff',
navIconInactive: '#949494',
},
forms: {
error: '#FF21D0',
border: '#e7edf3',
text: '#001137',
addonBackground: '#f4f4f4',
addonForeground: '#3e3f42',
selectionBackground: '#fff',
},
icons: { outlined: '#273498' },
modals: {
buttons: { background: '#fff' },
},
settings: { delete: '#CD0BC3' },
qr: {
copyBorderCash: '#00ABE7',
copyBorderToken: '#FF21D0',
background: '#fff',
token: '#231F20',
shadow: 'rgb(136 172 243 / 25%) 0px 10px 30px, rgb(0 0 0 / 3%) 0px 1px 1px, rgb(0 51 167 / 10%) 0px 10px 20px',
},
buttons: {
primary: {
backgroundImage:
'linear-gradient(270deg, #0074C2 0%, #273498 100%)',
color: '#fff',
hoverShadow: '0px 3px 10px -5px rgba(0, 0, 0, 0.75)',
+ disabledOverlay: 'rgba(255, 255, 255, 0.5)',
},
secondary: {
background: '#e9eaed',
color: '#444',
hoverShadow: '0px 3px 10px -5px rgba(0, 0, 0, 0.75)',
+ disabledOverlay: 'rgba(255, 255, 255, 0.5)',
},
},
collapses: {
background: '#fbfcfd',
border: '#eaedf3',
color: '#3e3f42',
},
+ generalSettings: {
+ item: {
+ icon: '#949494',
+ title: '#949494',
+ },
+ background: '#fff',
+ },
};
diff --git a/web/cashtab/src/components/App.js b/web/cashtab/src/components/App.js
index c2faf7e37..07814eb80 100644
--- a/web/cashtab/src/components/App.js
+++ b/web/cashtab/src/components/App.js
@@ -1,388 +1,393 @@
import React, { useState, useEffect } from 'react';
import 'antd/dist/antd.less';
import { Modal, Spin } from 'antd';
import { CashLoadingIcon } from '@components/Common/CustomIcons';
import '../index.css';
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
import { theme } from '@assets/styles/theme';
import {
FolderOpenFilled,
CaretRightOutlined,
SettingFilled,
AppstoreAddOutlined,
} from '@ant-design/icons';
import Wallet from '@components/Wallet/Wallet';
import Tokens from '@components/Tokens/Tokens';
import Send from '@components/Send/Send';
import SendToken from '@components/Send/SendToken';
import Configure from '@components/Configure/Configure';
import NotFound from '@components/NotFound';
import CashTab from '@assets/cashtab_xec.png';
import './App.css';
import { WalletContext } from '@utils/context';
import { isValidStoredWallet } from '@utils/cashMethods';
import WalletLabel from '@components/Common/WalletLabel.js';
import {
Route,
Redirect,
Switch,
useLocation,
useHistory,
} from 'react-router-dom';
// Easter egg imports not used in extension/src/components/App.js
import TabCash from '@assets/tabcash.png';
import ABC from '@assets/logo_topright.png';
import { checkForTokenById } from '@utils/tokenMethods.js';
import { currency } from './Common/Ticker';
+import ProtectableComponentWrapper from './Authentication/ProtectableComponentWrapper';
const GlobalStyle = createGlobalStyle`
.ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button, .ant-modal > button, .ant-modal-confirm-btns > button, .ant-modal-footer > button, #cropControlsConfirm {
border-radius: 8px;
background-color: ${props => props.theme.modals.buttons.background};
color: ${props => props.theme.wallet.text.secondary};
font-weight: bold;
}
.ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button:hover,.ant-modal-confirm-btns > button:hover, .ant-modal-footer > button:hover, #cropControlsConfirm:hover {
color: ${props => props.theme.primary};
transition: color 0.3s;
background-color: ${props => props.theme.modals.buttons.background};
}
.selectedCurrencyOption {
text-align: left;
color: ${props => props.theme.wallet.text.secondary} !important;
background-color: ${props => props.theme.contrast} !important;
}
.cashLoadingIcon {
color: ${props => props.theme.primary} !important;
font-size: 48px !important;
}
.selectedCurrencyOption:hover {
color: ${props => props.theme.contrast} !important;
background-color: ${props => props.theme.primary} !important;
}
#addrSwitch, #cropSwitch {
.ant-switch-checked {
background-color: white !important;
}
}
#addrSwitch.ant-switch-checked, #cropSwitch.ant-switch-checked {
background-image: ${props =>
props.theme.buttons.primary.backgroundImage} !important;
}
.ant-slider-rail {
background-color: ${props => props.theme.forms.border} !important;
}
.ant-slider-track {
background-color: ${props => props.theme.primary} !important;
}
`;
const CustomApp = styled.div`
text-align: center;
font-family: 'Gilroy', sans-serif;
background-color: ${props => props.theme.app.background};
`;
const Footer = styled.div`
z-index: 2;
background-color: ${props => props.theme.footer.background};
border-radius: 20px 20px 0 0;
position: fixed;
bottom: 0;
width: 500px;
box-shadow: rgb(136 172 243 / 25%) 0px 10px 30px,
rgb(0 0 0 / 3%) 0px 1px 1px, rgb(0 51 167 / 10%) 0px 10px 20px;
@media (max-width: 768px) {
width: 100%;
}
`;
export const NavButton = styled.button`
:focus,
:active {
outline: none;
}
cursor: pointer;
padding: 24px 12px 12px 12px;
margin: 0 28px;
@media (max-width: 475px) {
margin: 0 20px;
}
@media (max-width: 420px) {
margin: 0 12px;
}
@media (max-width: 350px) {
margin: 0 8px;
}
background-color: ${props => props.theme.footer.background};
border: none;
font-size: 10.5px;
font-weight: bold;
.anticon {
display: block;
color: ${props => props.theme.footer.navIconInactive};
font-size: 24px;
margin-bottom: 6px;
}
${({ active, ...props }) =>
active &&
`
color: ${props.theme.primary};
.anticon {
color: ${props.theme.primary};
}
`}
`;
export const WalletBody = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 100vh;
background-image: ${props => props.theme.app.sidebars};
background-attachment: fixed;
`;
export const WalletCtn = styled.div`
position: relative;
width: 500px;
background-color: ${props => props.theme.footerBackground};
min-height: 100vh;
padding: 10px 30px 120px 30px;
background: ${props => props.theme.wallet.background};
-webkit-box-shadow: 0px 0px 24px 1px ${props => props.theme.wallet.shadow};
-moz-box-shadow: 0px 0px 24px 1px ${props => props.theme.wallet.shadow};
box-shadow: 0px 0px 24px 1px ${props => props.theme.wallet.shadow};
@media (max-width: 768px) {
width: 100%;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
`;
export const HeaderCtn = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 20px 0 30px;
margin-bottom: 20px;
justify-content: space-between;
a {
color: ${props => props.theme.wallet.text.secondary};
:hover {
color: ${props => props.theme.primary};
}
}
@media (max-width: 768px) {
a {
font-size: 12px;
}
padding: 10px 0 20px;
}
`;
export const CashTabLogo = styled.img`
width: 120px;
@media (max-width: 768px) {
width: 110px;
}
`;
// AbcLogo styled component not included in extension, replaced by open in new tab link
export const AbcLogo = styled.img`
width: 150px;
@media (max-width: 768px) {
width: 120px;
}
`;
// Easter egg styled component not used in extension/src/components/App.js
export const EasterEgg = styled.img`
position: fixed;
bottom: -195px;
margin: 0;
right: 10%;
transition-property: bottom;
transition-duration: 1.5s;
transition-timing-function: ease-out;
:hover {
bottom: 0;
}
@media screen and (max-width: 1250px) {
display: none;
}
`;
const App = () => {
const ContextValue = React.useContext(WalletContext);
const { wallet, loading } = ContextValue;
const [loadingUtxosAfterSend, setLoadingUtxosAfterSend] = useState(false);
// If wallet is unmigrated, do not show page until it has migrated
// An invalid wallet will be validated/populated after the next API call, ETA 10s
const validWallet = isValidStoredWallet(wallet);
const location = useLocation();
const history = useHistory();
const selectedKey =
location && location.pathname ? location.pathname.substr(1) : '';
// Easter egg boolean not used in extension/src/components/App.js
const hasTab = validWallet
? checkForTokenById(
wallet.state.tokens,
'50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e',
)
: false;
useEffect(() => {
// If URL is not as specified in currency.appURL in Ticker.js, show a popup
const currentUrl = window.location.hostname;
if (currentUrl !== currency.appUrl) {
console.log(
`Loaded URL ${currentUrl} does not match app URL ${currency.appUrl}!`,
);
Modal.warning({
title: 'Cashtab is moving!',
content: (
Cashtab is moving to a new home at{' '}
Cashtab.com
Please write down your wallet 12-word seed and
import it at the new domain.
At the end of the month, cashtabapp.com will
auto-fwd to cashtab.com after one minute.
),
});
}
}, []);
return (
{/*Begin component not included in extension as desktop only*/}
{hasTab && (
)}
{/*End component not included in extension as desktop only*/}
{/*Begin component not included in extension as replaced by open in tab link*/}
{/*Begin component not included in extension as replaced by open in tab link*/}
-
-
-
-
-
-
-
-
-
-
-
- (
-
+
+
+
+
+
+
+
- )}
- />
-
-
-
-
-
-
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
{wallet ? (
) : null}
);
};
export default App;
diff --git a/web/cashtab/src/components/Authentication/ProtectableComponentWrapper.js b/web/cashtab/src/components/Authentication/ProtectableComponentWrapper.js
new file mode 100644
index 000000000..5220a81c4
--- /dev/null
+++ b/web/cashtab/src/components/Authentication/ProtectableComponentWrapper.js
@@ -0,0 +1,32 @@
+import React, { useContext } from 'react';
+import { AuthenticationContext } from '@utils/context';
+import SignUp from './SignUp';
+import SignIn from './SignIn';
+
+const ProtectableComponentWrapper = ({ children }) => {
+ const authentication = useContext(AuthenticationContext);
+
+ if (authentication) {
+ const { loading, isAuthenticationRequired, isSignedIn } =
+ authentication;
+
+ if (loading) {
+ return Loading authenticaion data...
;
+ }
+
+ // prompt if user would like to enable biometric lock when the app first run
+ if (isAuthenticationRequired === undefined) {
+ return ;
+ }
+
+ // prompt user to sign in
+ if (isAuthenticationRequired && !isSignedIn) {
+ return ;
+ }
+ }
+
+ // authentication = null => authentication is not supported
+ return <>{children}>;
+};
+
+export default ProtectableComponentWrapper;
diff --git a/web/cashtab/src/components/Authentication/SignIn.js b/web/cashtab/src/components/Authentication/SignIn.js
new file mode 100644
index 000000000..a1c188e3f
--- /dev/null
+++ b/web/cashtab/src/components/Authentication/SignIn.js
@@ -0,0 +1,155 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Modal, Spin } from 'antd';
+import styled from 'styled-components';
+import { AuthenticationContext } from '@utils/context';
+import { ThemedLockOutlined } from '@components/Common/CustomIcons';
+import PrimaryButton from '@components/Common/PrimaryButton';
+import { ReactComponent as FingerprintSVG } from '@assets/fingerprint-solid.svg';
+
+const StyledSignIn = styled.div`
+ h2 {
+ color: ${props => props.theme.wallet.text.primary};
+ font-size: 25px;
+ }
+ p {
+ color: ${props => props.theme.wallet.text.secondary};
+ }
+`;
+
+const UnlockButton = styled(PrimaryButton)`
+ position: relative;
+ width: auto;
+ margin: 30px auto;
+ padding: 20px 30px;
+
+ svg {
+ fill: ${props => props.theme.buttons.primary.color};
+ }
+
+ @media (max-width: 768px) {
+ font-size: 16px;
+ padding: 15px 20px;
+ }
+
+ :disabled {
+ cursor: not-allowed;
+ box-shadow: none;
+ ::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: ${props => props.theme.buttons.primary.disabledOverlay};
+ z-index: 10;
+ }
+ }
+`;
+
+const StyledFingerprintIcon = styled.div`
+ width: 48px;
+ height: 48px;
+ margin: auto;
+`;
+
+const SignIn = () => {
+ const authentication = useContext(AuthenticationContext);
+ const [isVisible, setIsVisible] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleDocVisibilityChange = () => {
+ document.visibilityState === 'visible'
+ ? setIsVisible(true)
+ : setIsVisible(false);
+ };
+
+ const handleSignIn = async () => {
+ try {
+ setIsLoading(true);
+ await authentication.signIn();
+ } catch (err) {
+ Modal.error({
+ title: 'Authentication Error',
+ content: 'Cannot get Credential from your device',
+ centered: true,
+ });
+ }
+ setIsLoading(false);
+ };
+
+ const handleSignInAndSuppressError = async () => {
+ try {
+ setIsLoading(true);
+ await authentication.signIn();
+ } catch (err) {
+ // fail silently
+ }
+ setIsLoading(false);
+ };
+
+ useEffect(() => {
+ if (document.visibilityState === 'visible') {
+ setIsVisible(true);
+ }
+ document.addEventListener(
+ 'visibilitychange',
+ handleDocVisibilityChange,
+ );
+
+ return () => {
+ document.removeEventListener(
+ 'visibilitychange',
+ handleDocVisibilityChange,
+ );
+ };
+ }, []);
+
+ useEffect(() => {
+ // This will trigger the plaform authenticator as soon as the component becomes visible
+ // (when switch back to this app), without any user gesture (such as clicking a button)
+ // In platforms that require user gesture in order to invoke the platform authenticator,
+ // this will fail. We want it to fail silently, and then show user a button to activate
+ // the platform authenticator
+ if (isVisible && authentication) {
+ handleSignInAndSuppressError();
+ }
+ }, [isVisible]);
+
+ let signInBody;
+ if (authentication) {
+ signInBody = (
+ <>
+
+ This wallet can be unlocked with your{' '}
+ fingerprint / device pin
+
+
+
+
+
+ Unlock
+
+
+ {isLoading ? : ''}
+
+ >
+ );
+ } else {
+ signInBody = Authentication is not supported
;
+ }
+
+ return (
+
+
+ Wallet Unlock
+
+ {signInBody}
+
+ );
+};
+
+export default SignIn;
diff --git a/web/cashtab/src/components/Authentication/SignUp.js b/web/cashtab/src/components/Authentication/SignUp.js
new file mode 100644
index 000000000..c0aeea289
--- /dev/null
+++ b/web/cashtab/src/components/Authentication/SignUp.js
@@ -0,0 +1,74 @@
+import React, { useContext } from 'react';
+import { Modal } from 'antd';
+import styled from 'styled-components';
+import { AuthenticationContext } from '@utils/context';
+import { ThemedLockOutlined } from '@components/Common/CustomIcons';
+import PrimaryButton, {
+ SecondaryButton,
+} from '@components/Common/PrimaryButton';
+
+const StyledSignUp = styled.div`
+ h2 {
+ color: ${props => props.theme.wallet.text.primary};
+ font-size: 25px;
+ }
+ p {
+ color: ${props => props.theme.wallet.text.secondary};
+ }
+`;
+
+const SignUp = () => {
+ const authentication = useContext(AuthenticationContext);
+
+ const handleSignUp = async () => {
+ try {
+ await authentication.signUp();
+ } catch (err) {
+ Modal.error({
+ title: 'Registration Error',
+ content: 'Cannot create Credential on your device',
+ centered: true,
+ });
+ }
+ };
+
+ let signUpBody;
+ if (authentication) {
+ signUpBody = (
+
+
Enable wallet lock to protect your fund.
+
+ You will need to unlock with your{' '}
+ fingerprint / device pin in order to access
+ the wallet.
+
+
+ This lock can also be enabled / disabled under
+
+ Settings / General Settings / App Lock
+
+
+ Enable Lock
+
+
authentication.turnOffAuthentication()}
+ >
+ Skip
+
+
+ );
+ } else {
+ signUpBody = Authentication is not supported
;
+ }
+
+ return (
+
+
+ Wallet Lock
+
+ {signUpBody}
+
+ );
+};
+
+export default SignUp;
diff --git a/web/cashtab/src/components/Common/CustomIcons.js b/web/cashtab/src/components/Common/CustomIcons.js
index 3f86a0917..59ae456c3 100644
--- a/web/cashtab/src/components/Common/CustomIcons.js
+++ b/web/cashtab/src/components/Common/CustomIcons.js
@@ -1,66 +1,74 @@
import * as React from 'react';
import styled from 'styled-components';
import {
CopyOutlined,
DollarOutlined,
LoadingOutlined,
WalletOutlined,
QrcodeOutlined,
+ SettingOutlined,
+ LockOutlined,
} from '@ant-design/icons';
import { Image } from 'antd';
import { currency } from '@components/Common/Ticker';
export const CashLoadingIcon = ;
export const CashReceivedNotificationIcon = () => (
);
export const TokenReceivedNotificationIcon = () => (
);
export const MessageSignedNotificationIcon = () => (
);
export const ThemedCopyOutlined = styled(CopyOutlined)`
color: ${props => props.theme.icons.outlined} !important;
`;
export const ThemedDollarOutlined = styled(DollarOutlined)`
color: ${props => props.theme.icons.outlined} !important;
`;
export const ThemedWalletOutlined = styled(WalletOutlined)`
color: ${props => props.theme.icons.outlined} !important;
`;
export const ThemedQrcodeOutlined = styled(QrcodeOutlined)`
color: ${props => props.theme.icons.outlined} !important;
`;
+export const ThemedSettingOutlined = styled(SettingOutlined)`
+ color: ${props => props.theme.icons.outlined} !important;
+`;
+export const ThemedLockOutlined = styled(LockOutlined)`
+ color: ${props => props.theme.icons.outlined} !important;
+`;
export const LoadingBlock = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
flex-direction: column;
svg {
width: 50px;
height: 50px;
fill: ${props => props.theme.primary};
}
`;
export const CashLoader = () => (
);
diff --git a/web/cashtab/src/components/Configure/Configure.js b/web/cashtab/src/components/Configure/Configure.js
index b858f0213..2faf4eb0b 100644
--- a/web/cashtab/src/components/Configure/Configure.js
+++ b/web/cashtab/src/components/Configure/Configure.js
@@ -1,680 +1,751 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
-import { Collapse, Form, Input, Modal, Alert } from 'antd';
+import { Collapse, Form, Input, Modal, Alert, Switch, Tag } from 'antd';
import {
PlusSquareOutlined,
WalletFilled,
ImportOutlined,
LockOutlined,
+ CheckOutlined,
+ CloseOutlined,
+ LockFilled,
+ ExclamationCircleFilled,
} from '@ant-design/icons';
-import { WalletContext } from '@utils/context';
+import { WalletContext, AuthenticationContext } from '@utils/context';
import { StyledCollapse } from '@components/Common/StyledCollapse';
import {
AntdFormWrapper,
CurrencySelectDropdown,
} from '@components/Common/EnhancedInputs';
import PrimaryButton, {
SecondaryButton,
SmartButton,
} from '@components/Common/PrimaryButton';
import {
ThemedCopyOutlined,
ThemedWalletOutlined,
ThemedDollarOutlined,
+ ThemedSettingOutlined,
} from '@components/Common/CustomIcons';
import { ReactComponent as Trashcan } from '@assets/trashcan.svg';
import { ReactComponent as Edit } from '@assets/edit.svg';
import { Event } from '@utils/GoogleAnalytics';
import ApiError from '@components/Common/ApiError';
import { formatSavedBalance } from '@utils/validation';
const { Panel } = Collapse;
const SettingsLink = styled.a`
text-decoration: underline;
color: ${props => props.theme.primary};
:visited {
text-decoration: underline;
color: ${props => props.theme.primary};
}
:hover {
color: ${props => props.theme.brandSecondary};
}
`;
const SWRow = styled.div`
border-radius: 3px;
padding: 10px 0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
@media (max-width: 500px) {
flex-direction: column;
margin-bottom: 12px;
}
`;
const SWName = styled.div`
width: 50%;
display: flex;
align-items: center;
justify-content: space-between;
word-wrap: break-word;
hyphens: auto;
@media (max-width: 500px) {
width: 100%;
justify-content: center;
margin-bottom: 15px;
}
h3 {
font-size: 16px;
color: ${props => props.theme.wallet.text.secondary};
margin: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
h3.overflow {
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
h3.overflow:hover {
background-color: #eee;
overflow: visible;
inline-size: 100px;
white-space: normal;
}
`;
const SWBalance = styled.div`
width: 50%;
display: flex;
align-items: center;
justify-content: space-between;
word-wrap: break-word;
hyphens: auto;
@media (max-width: 500px) {
width: 100%;
justify-content: center;
margin-bottom: 15px;
}
div {
font-size: 13px;
color: ${props => props.theme.wallet.text.secondary};
margin: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
div.overflow {
width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
div.overflow:hover {
background-color: #eee;
overflow: visible;
inline-size: 150px;
white-space: normal;
}
`;
const SWButtonCtn = styled.div`
width: 50%;
display: flex;
align-items: center;
justify-content: flex-end;
@media (max-width: 500px) {
width: 100%;
justify-content: center;
}
button {
cursor: pointer;
@media (max-width: 768px) {
font-size: 14px;
}
}
svg {
stroke: ${props => props.theme.wallet.text.secondary};
fill: ${props => props.theme.wallet.text.secondary};
width: 25px;
height: 25px;
margin-right: 20px;
cursor: pointer;
:first-child:hover {
stroke: ${props => props.theme.primary};
fill: ${props => props.theme.primary};
}
:hover {
stroke: ${props => props.theme.settings.delete};
fill: ${props => props.theme.settings.delete};
}
}
`;
const AWRow = styled.div`
padding: 10px 0;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
h3 {
font-size: 16px;
display: inline-block;
color: ${props => props.theme.wallet.text.secondary};
margin: 0;
text-align: left;
font-weight: bold;
@media (max-width: 500px) {
font-size: 14px;
}
}
h4 {
font-size: 16px;
display: inline-block;
color: ${props => props.theme.primary} !important;
margin: 0;
text-align: right;
}
@media (max-width: 500px) {
flex-direction: column;
margin-bottom: 12px;
}
`;
const StyledConfigure = styled.div`
h2 {
color: ${props => props.theme.wallet.text.primary};
font-size: 25px;
}
p {
color: ${props => props.theme.wallet.text.secondary};
}
`;
const StyledSpacer = styled.div`
height: 1px;
width: 100%;
background-color: ${props => props.theme.wallet.borders.color};
margin: 60px 0 50px;
`;
+const GeneralSettingsItem = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ .title {
+ color: ${props => props.theme.generalSettings.item.title};
+ }
+ .anticon {
+ color: ${props => props.theme.generalSettings.item.icon};
+ }
+ .ant-switch {
+ background-color: ${props => props.theme.generalSettings.item.icon};
+ .anticon {
+ color: ${props => props.theme.generalSettings.background};
+ }
+ }
+ .ant-switch-checked {
+ background-color: ${props => props.theme.primary};
+ }
+`;
+
const Configure = () => {
const ContextValue = React.useContext(WalletContext);
+ const authentication = React.useContext(AuthenticationContext);
const { wallet, apiError } = ContextValue;
const {
addNewSavedWallet,
activateWallet,
renameWallet,
deleteWallet,
validateMnemonic,
getSavedWallets,
cashtabSettings,
changeCashtabSettings,
} = ContextValue;
const [savedWallets, setSavedWallets] = useState([]);
const [formData, setFormData] = useState({
dirty: true,
mnemonic: '',
});
const [showRenameWalletModal, setShowRenameWalletModal] = useState(false);
const [showDeleteWalletModal, setShowDeleteWalletModal] = useState(false);
const [walletToBeRenamed, setWalletToBeRenamed] = useState(null);
const [walletToBeDeleted, setWalletToBeDeleted] = useState(null);
const [newWalletName, setNewWalletName] = useState('');
const [
confirmationOfWalletToBeDeleted,
setConfirmationOfWalletToBeDeleted,
] = useState('');
const [newWalletNameIsValid, setNewWalletNameIsValid] = useState(null);
const [walletDeleteValid, setWalletDeleteValid] = useState(null);
const [seedInput, openSeedInput] = useState(false);
const [showTranslationWarning, setShowTranslationWarning] = useState(false);
const showPopulatedDeleteWalletModal = walletInfo => {
setWalletToBeDeleted(walletInfo);
setShowDeleteWalletModal(true);
};
const showPopulatedRenameWalletModal = walletInfo => {
setWalletToBeRenamed(walletInfo);
setShowRenameWalletModal(true);
};
const cancelRenameWallet = () => {
// Delete form value
setNewWalletName('');
setShowRenameWalletModal(false);
};
const cancelDeleteWallet = () => {
setWalletToBeDeleted(null);
setConfirmationOfWalletToBeDeleted('');
setShowDeleteWalletModal(false);
};
const updateSavedWallets = async activeWallet => {
if (activeWallet) {
let savedWallets;
try {
savedWallets = await getSavedWallets(activeWallet);
setSavedWallets(savedWallets);
} catch (err) {
console.log(`Error in getSavedWallets()`);
console.log(err);
}
}
};
const [isValidMnemonic, setIsValidMnemonic] = useState(null);
useEffect(() => {
// Update savedWallets every time the active wallet changes
updateSavedWallets(wallet);
}, [wallet]);
useEffect(() => {
const detectedBrowserLang = navigator.language;
if (!detectedBrowserLang.includes('en-')) {
setShowTranslationWarning(true);
}
}, []);
// Need this function to ensure that savedWallets are updated on new wallet creation
const updateSavedWalletsOnCreate = async importMnemonic => {
// Event("Category", "Action", "Label")
// Track number of times a different wallet is activated
Event('Configure.js', 'Create Wallet', 'New');
const walletAdded = await addNewSavedWallet(importMnemonic);
if (!walletAdded) {
Modal.error({
title: 'This wallet already exists!',
content: 'Wallet not added',
});
} else {
Modal.success({
content: 'Wallet added to your saved wallets',
});
}
await updateSavedWallets(wallet);
};
// Same here
// TODO you need to lock UI here until this is complete
// Otherwise user may try to load an already-loading wallet, wreak havoc with indexedDB
const updateSavedWalletsOnLoad = async walletToActivate => {
// Event("Category", "Action", "Label")
// Track number of times a different wallet is activated
Event('Configure.js', 'Activate', '');
await activateWallet(walletToActivate);
};
async function submit() {
setFormData({
...formData,
dirty: false,
});
// Exit if no user input
if (!formData.mnemonic) {
return;
}
// Exit if mnemonic is invalid
if (!isValidMnemonic) {
return;
}
// Event("Category", "Action", "Label")
// Track number of times a different wallet is activated
Event('Configure.js', 'Create Wallet', 'Imported');
updateSavedWalletsOnCreate(formData.mnemonic);
}
const handleChange = e => {
const { value, name } = e.target;
// Validate mnemonic on change
// Import button should be disabled unless mnemonic is valid
setIsValidMnemonic(validateMnemonic(value));
setFormData(p => ({ ...p, [name]: value }));
};
const changeWalletName = async () => {
if (newWalletName === '' || newWalletName.length > 24) {
setNewWalletNameIsValid(false);
return;
}
// Hide modal
setShowRenameWalletModal(false);
// Change wallet name
console.log(
`Changing wallet ${walletToBeRenamed.name} name to ${newWalletName}`,
);
const renameSuccess = await renameWallet(
walletToBeRenamed.name,
newWalletName,
);
if (renameSuccess) {
Modal.success({
content: `Wallet "${walletToBeRenamed.name}" renamed to "${newWalletName}"`,
});
} else {
Modal.error({
content: `Rename failed. All wallets must have a unique name.`,
});
}
await updateSavedWallets(wallet);
// Clear wallet name for form
setNewWalletName('');
};
const deleteSelectedWallet = async () => {
if (!walletDeleteValid && walletDeleteValid !== null) {
return;
}
if (
confirmationOfWalletToBeDeleted !==
`delete ${walletToBeDeleted.name}`
) {
setWalletDeleteValid(false);
return;
}
// Hide modal
setShowDeleteWalletModal(false);
// Change wallet name
console.log(`Deleting wallet "${walletToBeDeleted.name}"`);
const walletDeletedSuccess = await deleteWallet(walletToBeDeleted);
if (walletDeletedSuccess) {
Modal.success({
content: `Wallet "${walletToBeDeleted.name}" successfully deleted`,
});
} else {
Modal.error({
content: `Error deleting ${walletToBeDeleted.name}.`,
});
}
await updateSavedWallets(wallet);
// Clear wallet delete confirmation from form
setConfirmationOfWalletToBeDeleted('');
};
const handleWalletNameInput = e => {
const { value } = e.target;
// validation
if (value && value.length && value.length < 24) {
setNewWalletNameIsValid(true);
} else {
setNewWalletNameIsValid(false);
}
setNewWalletName(value);
};
const handleWalletToDeleteInput = e => {
const { value } = e.target;
if (value && value === `delete ${walletToBeDeleted.name}`) {
setWalletDeleteValid(true);
} else {
setWalletDeleteValid(false);
}
setConfirmationOfWalletToBeDeleted(value);
};
+ const handleAppLockToggle = (checked, e) => {
+ if (checked) {
+ // if there is an existing credential, that means user has registered
+ // simply turn on the Authentication Required flag
+ if (authentication.credentialId) {
+ authentication.turnOnAuthentication();
+ } else {
+ // there is no existing credential, that means user has not registered
+ // user need to register
+ authentication.signUp();
+ }
+ } else {
+ authentication.turnOffAuthentication();
+ }
+ };
+
return (
{walletToBeRenamed !== null && (
cancelRenameWallet()}
>
}
placeholder="Enter new wallet name"
name="newName"
value={newWalletName}
onChange={e => handleWalletNameInput(e)}
/>
)}
{walletToBeDeleted !== null && (
cancelDeleteWallet()}
>
}
placeholder={`Type "delete ${walletToBeDeleted.name}" to confirm`}
name="walletToBeDeletedInput"
value={confirmationOfWalletToBeDeleted}
onChange={e => handleWalletToDeleteInput(e)}
/>
)}
Backup your wallet
{showTranslationWarning && (
)}
{wallet && wallet.mnemonic && (
{wallet && wallet.mnemonic ? wallet.mnemonic : ''}
)}
Manage Wallets
{apiError ? (
) : (
<>
updateSavedWalletsOnCreate()}>
New Wallet
openSeedInput(!seedInput)}>
Import Wallet
{seedInput && (
<>
Copy and paste your mnemonic seed phrase below
to import an existing wallet
}
type="email"
placeholder="mnemonic (seed phrase)"
name="mnemonic"
autoComplete="off"
onChange={e => handleChange(e)}
required
/>
submit()}
>
Import
>
)}
>
)}
{savedWallets && savedWallets.length > 0 && (
<>
{wallet.name}
Currently active
{savedWallets.map(sw => (
{sw.name}
[
{sw && sw.state
? formatSavedBalance(
sw.state.balances
.totalBalance,
)
: 'N/A'}{' '}
XEC]
showPopulatedRenameWalletModal(
sw,
)
}
/>
showPopulatedDeleteWalletModal(
sw,
)
}
/>
))}
>
)}
Fiat Currency
changeCashtabSettings('fiatCurrency', fiatCode)
}
/>
+
+
+ General Settings
+
+
+
+ Lock App
+
+ {authentication ? (
+ }
+ unCheckedChildren={}
+ checked={
+ authentication.isAuthenticationRequired &&
+ authentication.credentialId
+ ? true
+ : false
+ }
+ // checked={false}
+ onChange={handleAppLockToggle}
+ />
+ ) : (
+ }>
+ Not Supported
+
+ )}
+
[
Documentation
]
);
};
export default Configure;
diff --git a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
index 6d2c9c82e..843e599e7 100644
--- a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
+++ b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
@@ -1,605 +1,781 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
exports[`Configure with a wallet 1`] = `
Backup your wallet
Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.
Click to reveal seed phrase
Manage Wallets
Fiat Currency
+
+
+
+
+ General Settings
+
+
+
[
Documentation
]
`;
exports[`Configure without a wallet 1`] = `
Backup your wallet
Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.
Manage Wallets
Fiat Currency
+
+
+
+
+ General Settings
+
+
+
[
Documentation
]
`;
diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap
index 797543c54..4236ef6b6 100644
--- a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap
+++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap
@@ -1,2199 +1,2199 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Wallet with BCH balances 1`] = `
Array [
You currently have 0
XEC
Deposit some funds to use this feature
,
,
Signatures
,
,
]
`;
exports[`Wallet with BCH balances and tokens 1`] = `
Array [
You currently have 0
XEC
Deposit some funds to use this feature
,
,
Signatures
,
,
]
`;
exports[`Wallet with BCH balances and tokens and state field 1`] = `
Array [
0.06
XEC
,
$
NaN
USD
,
,
Signatures
,
,
]
`;
exports[`Wallet without BCH balance 1`] = `
Array [
You currently have 0
XEC
Deposit some funds to use this feature
,
,
Signatures
,
,
]
`;
exports[`Without wallet defined 1`] = `
Array [
You currently have 0
XEC
Deposit some funds to use this feature
,
,
Signatures
,
,
]
`;
diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap
index aeb1c269c..82a8af961 100644
--- a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap
+++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap
@@ -1,289 +1,289 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
exports[`Wallet with BCH balances and tokens 1`] = `null`;
exports[`Wallet with BCH balances and tokens and state field 1`] = `
Array [
6.001
TBS
,
If you would like to request an icon for an eToken that has already been created, please email icons@e.cash.
,
,
]
`;
exports[`Without wallet defined 1`] = `null`;
diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
index e1c0b2ca6..521573ded 100644
--- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
+++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
@@ -1,47 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
exports[`Wallet with BCH balances and tokens and state field 1`] = `
`;
diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
index 415e43530..3a526a07b 100644
--- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
+++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
@@ -1,642 +1,642 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
exports[`Wallet with BCH balances 1`] = `
Array [
You need some
XEC
in your wallet to create tokens.
,
0
XEC
,
If you would like to request an icon for an eToken that has already been created, please email icons@e.cash.
,
,
You need at least
5.5
XEC
(
$
NaN
USD
) to create a token
,
"No ",
"eToken",
" tokens in this wallet",
]
`;
exports[`Wallet with BCH balances and tokens 1`] = `
Array [
You need some
XEC
in your wallet to create tokens.
,
0
XEC
,
If you would like to request an icon for an eToken that has already been created, please email icons@e.cash.
,
,
You need at least
5.5
XEC
(
$
NaN
USD
) to create a token
,
"No ",
"eToken",
" tokens in this wallet",
]
`;
exports[`Wallet with BCH balances and tokens and state field 1`] = `
Array [
0.06
XEC
,
$
NaN
USD
,
If you would like to request an icon for an eToken that has already been created, please email icons@e.cash.
,
,
,
]
`;
exports[`Wallet without BCH balance 1`] = `
Array [
You need some
XEC
in your wallet to create tokens.
,
0
XEC
,
If you would like to request an icon for an eToken that has already been created, please email icons@e.cash.
,
,
You need at least
5.5
XEC
(
$
NaN
USD
) to create a token
,
"No ",
"eToken",
" tokens in this wallet",
]
`;
exports[`Without wallet defined 1`] = `
Array [
You need some
XEC
in your wallet to create tokens.
,
0
XEC
,
If you would like to request an icon for an eToken that has already been created, please email icons@e.cash.
,
,
You need at least
5.5
XEC
(
$
NaN
USD
) to create a token
,
"No ",
"eToken",
" tokens in this wallet",
]
`;
diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
index 1e617dd3b..774608643 100644
--- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
+++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
@@ -1,622 +1,622 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Wallet with BCH balances 1`] = `
Array [
🎉
Congratulations on your new wallet!
🎉
Start using the wallet immediately to receive
XEC
payments, or load it up with
XEC
to send to others
,
0
XEC
,
,
,
]
`;
exports[`Wallet with BCH balances and tokens 1`] = `
Array [
🎉
Congratulations on your new wallet!
🎉
Start using the wallet immediately to receive
XEC
payments, or load it up with
XEC
to send to others
,
0
XEC
,
,
,
]
`;
exports[`Wallet with BCH balances and tokens and state field 1`] = `
Array [
0.06
XEC
,
$
NaN
USD
,
,
,
]
`;
exports[`Wallet without BCH balance 1`] = `
Array [
🎉
Congratulations on your new wallet!
🎉
Start using the wallet immediately to receive
XEC
payments, or load it up with
XEC
to send to others
,
0
XEC
,
,
,
]
`;
exports[`Without wallet defined 1`] = `
Array [
Welcome to Cashtab!
,
Cashtab is an
open source,
non-custodial web wallet for
eCash
.
Want to learn more?
Check out the Cashtab documentation.
,
,
,
]
`;
diff --git a/web/cashtab/src/hooks/useWebAuthentication.js b/web/cashtab/src/hooks/useWebAuthentication.js
new file mode 100644
index 000000000..6b4484098
--- /dev/null
+++ b/web/cashtab/src/hooks/useWebAuthentication.js
@@ -0,0 +1,238 @@
+import { useState, useEffect } from 'react';
+import localforage from 'localforage';
+import { currency } from '@components/Common/Ticker';
+import {
+ convertBase64ToArrayBuffer,
+ convertArrayBufferToBase64,
+} from '@utils/convertArrBuffBase64';
+
+// return an Authentication Object
+// OR null if user device does not support Web Authentication
+const useWebAuthentication = () => {
+ const [isWebAuthnSupported, setIsWebAuthnSupported] = useState(false);
+ // Possible values of isAuthenticationRequired:
+ // true - YES, authentication is required
+ // false - NO, authentication is not required
+ // undefined - has not been set, this is the first time the app runs
+ const [isAuthenticationRequired, setIsAuthenticationRequired] =
+ useState(undefined);
+ const [credentialId, setCredentialId] = useState(null);
+ const [isSignedIn, setIsSignedIn] = useState(false);
+ const [userId, setUserId] = useState(Date.now().toString(16));
+ const [loading, setLoading] = useState(true);
+
+ const loadAuthenticationConfigFromLocalStorage = async () => {
+ // try to load authentication configuration from local storage
+ try {
+ return await localforage.getItem('authenticationConfig');
+ } catch (err) {
+ console.error(
+ 'Error is localforange.getItem("authenticatonConfig") in loadAuthenticationConfigFromLocalStorage() in useWebAuthentication()',
+ );
+ // Should stop when attempting to read from localstorage failed
+ // countinuing would prompt user to register new credential
+ // that would risk overrididing existing credential
+ throw err;
+ }
+ };
+
+ const saveAuthenticationConfigToLocalStorage = () => {
+ try {
+ return localforage.setItem('authenticationConfig', {
+ isAuthenticationRequired,
+ userId,
+ credentialId,
+ });
+ } catch (err) {
+ console.error(
+ 'Error is localforange.setItem("authenticatonConfig") in saveAuthenticationConfigToLocalStorage() in useWebAuthentication()',
+ );
+ throw err;
+ }
+ };
+
+ // Run Once
+ useEffect(async () => {
+ // check to see if user device supports User Verification
+ const available =
+ await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ // only attempt to save/load authentication configuration from local storage if web authetication is supported
+ if (available) {
+ const authenticationConfig =
+ await loadAuthenticationConfigFromLocalStorage();
+ // if this is the first time the app is run, then save the default config value
+ if (authenticationConfig === null) {
+ saveAuthenticationConfigToLocalStorage();
+ } else {
+ setUserId(authenticationConfig.userId);
+ setCredentialId(authenticationConfig.credentialId);
+ setIsAuthenticationRequired(
+ authenticationConfig.isAuthenticationRequired,
+ );
+ }
+ // signout the user when the app is not visible (minimize the browser, switch tab, switch app window)
+ const handleDocVisibilityChange = () => {
+ if (document.visibilityState !== 'visible')
+ setIsSignedIn(false);
+ };
+ document.addEventListener(
+ 'visibilitychange',
+ handleDocVisibilityChange,
+ );
+
+ setIsWebAuthnSupported(available);
+ setLoading(false);
+
+ return () => {
+ document.removeEventListener(
+ 'visibilitychange',
+ handleDocVisibilityChange,
+ );
+ };
+ }
+ }, []);
+
+ // save the config whenever it is changed
+ useEffect(async () => {
+ if (isAuthenticationRequired === undefined) return;
+ await saveAuthenticationConfigToLocalStorage();
+ }, [isAuthenticationRequired, credentialId]);
+
+ // options for PublicKeyCredentialCreation
+ const publicKeyCredentialCreationOptions = {
+ // hardcode for now
+ // consider generating random string and then verifying it against the reponse from authenticator
+ challenge: Uint8Array.from('cashtab-wallet-for-ecash', c =>
+ c.charCodeAt(0),
+ ),
+ rp: {
+ name: currency.name,
+ id: document.domain,
+ },
+ user: {
+ id: Uint8Array.from(userId, c => c.charCodeAt(0)),
+ name: `Local User`,
+ displayName: 'Local User',
+ },
+ pubKeyCredParams: [
+ { alg: -7, type: 'public-key' },
+ { alg: -35, type: 'public-key' },
+ { alg: -36, type: 'public-key' },
+ { alg: -257, type: 'public-key' },
+ { alg: -258, type: 'public-key' },
+ { alg: -259, type: 'public-key' },
+ { alg: -37, type: 'public-key' },
+ { alg: -38, type: 'public-key' },
+ { alg: -39, type: 'public-key' },
+ { alg: -8, type: 'public-key' },
+ ],
+ authenticatorSelection: {
+ userVerification: 'required',
+ authenticatorAttachment: 'platform',
+ requireResidentKey: false,
+ },
+ timeout: 60000,
+ attestation: 'none',
+ excludeCredentials: [],
+ extensions: {},
+ };
+
+ // options for PublicKeyCredentialRequest
+ const publickKeyRequestOptions = {
+ // hardcode for now
+ // consider generating random string and then verifying it against the reponse from authenticator
+ challenge: Uint8Array.from('cashtab-wallet-for-ecash', c =>
+ c.charCodeAt(0),
+ ),
+ timeout: 60000,
+ // rpId: document.domain,
+ allowCredentials: [
+ {
+ type: 'public-key',
+ // the credentialId is stored as base64
+ // need to convert it to ArrayBuffer
+ id: convertBase64ToArrayBuffer(credentialId),
+ transports: ['internal'],
+ },
+ ],
+ userVerification: 'required',
+ extensions: {},
+ };
+
+ const authentication = {
+ isAuthenticationRequired,
+ credentialId,
+ isSignedIn,
+ loading,
+ turnOnAuthentication: () => {
+ // Need to make sure that the credetialId is set
+ // before turning on the authentication requirement
+ // otherwise, user will be locked out of the app
+ // in other words, user must sign up / register first
+ if (credentialId) {
+ setIsAuthenticationRequired(true);
+ }
+ },
+ turnOffAuthentication: () => {
+ setIsAuthenticationRequired(false);
+ },
+
+ signUp: async () => {
+ try {
+ const publicKeyCredential = await navigator.credentials.create({
+ publicKey: publicKeyCredentialCreationOptions,
+ });
+ if (publicKeyCredential) {
+ // convert the rawId from ArrayBuffer to base64 String
+ const base64Id = convertArrayBufferToBase64(
+ publicKeyCredential.rawId,
+ );
+ setIsSignedIn(true);
+ setCredentialId(base64Id);
+ setIsAuthenticationRequired(true);
+ } else {
+ throw new Error(
+ 'Error: navigator.credentials.create() returns null, in signUp()',
+ );
+ }
+ } catch (err) {
+ throw err;
+ }
+ },
+
+ signIn: async () => {
+ try {
+ const assertion = await navigator.credentials.get({
+ publicKey: publickKeyRequestOptions,
+ });
+ if (assertion) {
+ // convert rawId from ArrayBuffer to base64 String
+ const base64Id = convertArrayBufferToBase64(
+ assertion.rawId,
+ );
+ if (base64Id === credentialId) setIsSignedIn(true);
+ } else {
+ throw new Error(
+ 'Error: navigator.credentials.get() returns null, signIn()',
+ );
+ }
+ } catch (err) {
+ throw err;
+ }
+ },
+
+ signOut: () => {
+ setIsSignedIn(false);
+ },
+ };
+
+ // Web Authentication support on a user's device may become unavailable due to various reasons
+ // (hardware failure, OS problems, the behaviour of some authenticators after several failed authentication attempts, etc)
+ // If this is the case, and user has previous enabled the lock, the decision here is to lock up the wallet.
+ // Otherwise, malicious user needs to simply disbale the platform authenticator to gain access to the wallet
+ return !isWebAuthnSupported && !isAuthenticationRequired
+ ? null
+ : authentication;
+};
+
+export default useWebAuthentication;
diff --git a/web/cashtab/src/index.js b/web/cashtab/src/index.js
index 9d36c566b..87fdbce87 100644
--- a/web/cashtab/src/index.js
+++ b/web/cashtab/src/index.js
@@ -1,27 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './components/App';
-import { WalletProvider } from './utils/context';
+import { AuthenticationProvider, WalletProvider } from './utils/context';
import { HashRouter as Router } from 'react-router-dom';
import GA from './utils/GoogleAnalytics';
ReactDOM.render(
-
-
- {GA.init() && }
-
-
- ,
+
+
+
+ {GA.init() && }
+
+
+
+ ,
document.getElementById('root'),
);
if ('serviceWorker' in navigator) {
window.addEventListener('load', () =>
navigator.serviceWorker.register('/serviceWorker.js').catch(() => null),
);
}
if (module.hot) {
module.hot.accept();
}
diff --git a/web/cashtab/src/utils/context.js b/web/cashtab/src/utils/context.js
index b79b8ec34..698cb3560 100644
--- a/web/cashtab/src/utils/context.js
+++ b/web/cashtab/src/utils/context.js
@@ -1,12 +1,26 @@
import React from 'react';
import useWallet from '../hooks/useWallet';
export const WalletContext = React.createContext();
export const WalletProvider = ({ children }) => {
const wallet = useWallet();
return (
{children}
);
};
+
+// Authentication Context
+import useWebAuthentication from '../hooks/useWebAuthentication';
+export const AuthenticationContext = React.createContext();
+export const AuthenticationProvider = ({ children }) => {
+ // useWebAuthentication returns null if Web Authn is not supported
+ const authentication = useWebAuthentication();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/web/cashtab/src/utils/convertArrBuffBase64.js b/web/cashtab/src/utils/convertArrBuffBase64.js
new file mode 100644
index 000000000..d31923376
--- /dev/null
+++ b/web/cashtab/src/utils/convertArrBuffBase64.js
@@ -0,0 +1,19 @@
+export const convertArrayBufferToBase64 = buffer => {
+ // convert the buffer from ArrayBuffer to Array of 8-bit unsigned integers
+ const dataView = new Uint8Array(buffer);
+ // convert the Array of 8-bit unsigned integers to a String
+ const dataStr = dataView.reduce(
+ (str, cur) => str + String.fromCharCode(cur),
+ '',
+ );
+ // convert String to base64
+ return window.btoa(dataStr);
+};
+
+export const convertBase64ToArrayBuffer = base64Str => {
+ // convert base64 String to normal String
+ const dataStr = window.atob(base64Str);
+ // convert the String to an Array of 8-bit unsigned integers
+ const dataView = Uint8Array.from(dataStr, char => char.charCodeAt(0));
+ return dataView.buffer;
+};