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

US Dollar ($)
+

+ + + + General Settings +

+
+
+ + + + Lock App +
+ +
+ `; 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

US Dollar ($)
+

+ + + + General Settings +

+
+
+ + + + Lock App +
+ +
+ `; 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
,
XEC
max
= $ NaN USD
Advanced
,
Signatures
,
Sign Message
, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max
= $ NaN USD
Advanced
,
Signatures
,
Sign Message
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
0.06 XEC
,
$ NaN USD
,
XEC
max
= $ NaN USD
Advanced
,
Signatures
,
Sign Message
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max
= $ NaN USD
Advanced
,
Signatures
,
Sign Message
, ] `; exports[`Without wallet defined 1`] = ` Array [
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max
= $ NaN USD
Advanced
,
Signatures
,
Sign Message
, ] `; 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.
,
TBS max
, ] `; 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`] = `
Create eToken
`; 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.
,
Create eToken
,

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.
,
Create eToken
,

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.
,
Create eToken
,
, ] `; 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.
,
Create eToken
,

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.
,
Create eToken
,

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
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
XEC
eToken
, ] `; 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
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
XEC
eToken
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
0.06 XEC
,
$ NaN USD
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
XEC
eToken
, ] `; 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
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
XEC
eToken
, ] `; 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; +};