diff --git a/packages/desktop-client/src/components/Modals.js b/packages/desktop-client/src/components/Modals.js index 8b8d1686035b38917328c5e1979109d9a7426255..361de99e0f7e786ed3bdaf764575e02f9146c0fd 100644 --- a/packages/desktop-client/src/components/Modals.js +++ b/packages/desktop-client/src/components/Modals.js @@ -27,6 +27,7 @@ import LoadBackup from './modals/LoadBackup'; import ManageRulesModal from './modals/ManageRulesModal'; import MergeUnusedPayees from './modals/MergeUnusedPayees'; import NordigenExternalMsg from './modals/NordigenExternalMsg'; +import NordigenInitialise from './modals/NordigenInitialise'; import PlaidExternalMsg from './modals/PlaidExternalMsg'; import SelectLinkedAccounts from './modals/SelectLinkedAccounts'; @@ -219,6 +220,17 @@ function Modals({ ); }} /> + <Route + path="/nordigen-init" + render={() => { + return ( + <NordigenInitialise + modalProps={modalProps} + onSuccess={options.onSuccess} + /> + ); + }} + /> <Route path="/nordigen-external-msg" render={() => { diff --git a/packages/desktop-client/src/components/alerts.js b/packages/desktop-client/src/components/alerts.tsx similarity index 66% rename from packages/desktop-client/src/components/alerts.js rename to packages/desktop-client/src/components/alerts.tsx index 5856f1c007e3f961d02e9f15c425a035e8a7c8a4..0fcd81af30856c64de3d322f8314e5c793b7c049 100644 --- a/packages/desktop-client/src/components/alerts.js +++ b/packages/desktop-client/src/components/alerts.tsx @@ -1,12 +1,29 @@ import React from 'react'; +import type { CSSProperties } from 'glamor'; + import ExclamationOutline from '../icons/v1/ExclamationOutline'; import InformationOutline from '../icons/v1/InformationOutline'; import { styles, colors } from '../style'; -import { View, Text } from './common'; +import Text from './common/Text'; +import View from './common/View'; + +interface AlertProps { + icon?: React.FC<{ width?: number; style?: CSSProperties }>; + color?: string; + backgroundColor?: string; + style?: CSSProperties; + children?: React.ReactNode; +} -export function Alert({ icon: Icon, color, backgroundColor, style, children }) { +export const Alert: React.FC<AlertProps> = ({ + icon: Icon, + color, + backgroundColor, + style, + children, +}) => { return ( <View style={[ @@ -39,9 +56,17 @@ export function Alert({ icon: Icon, color, backgroundColor, style, children }) { <Text style={{ zIndex: 1, lineHeight: 1.5 }}>{children}</Text> </View> ); +}; + +interface ScopedAlertProps { + style?: CSSProperties; + children?: React.ReactNode; } -export function Information({ style, children }) { +export const Information: React.FC<ScopedAlertProps> = ({ + style, + children, +}) => { return ( <Alert icon={InformationOutline} @@ -52,9 +77,9 @@ export function Information({ style, children }) { {children} </Alert> ); -} +}; -export function Warning({ style, children }) { +export const Warning: React.FC<ScopedAlertProps> = ({ style, children }) => { return ( <Alert icon={ExclamationOutline} @@ -65,9 +90,9 @@ export function Warning({ style, children }) { {children} </Alert> ); -} +}; -export function Error({ style, children }) { +export const Error: React.FC<ScopedAlertProps> = ({ style, children }) => { return ( <Alert icon={ExclamationOutline} @@ -78,4 +103,4 @@ export function Error({ style, children }) { {children} </Alert> ); -} +}; diff --git a/packages/desktop-client/src/components/common.js b/packages/desktop-client/src/components/common.js index 899df70335d31a960752dc163d974690e6461ebd..a8129fc7b040409c611685b86ac52d0913e168b4 100644 --- a/packages/desktop-client/src/components/common.js +++ b/packages/desktop-client/src/components/common.js @@ -5,7 +5,6 @@ import React, { useState, useCallback, } from 'react'; -import ReactModal from 'react-modal'; import { Route, NavLink, withRouter, useRouteMatch } from 'react-router-dom'; import { @@ -16,22 +15,19 @@ import { ListboxOption, } from '@reach/listbox'; import { css } from 'glamor'; -import hotkeys from 'hotkeys-js'; import { integerToCurrency } from 'loot-core/src/shared/util'; -import Loading from '../icons/AnimatedLoading'; -import Delete from '../icons/v0/Delete'; import ExpandArrow from '../icons/v0/ExpandArrow'; import { styles, colors } from '../style'; -import tokens from '../tokens'; import Button from './common/Button'; import Input, { defaultInputStyle } from './common/Input'; import Text from './common/Text'; import View from './common/View'; -export { default as Button } from './common/Button'; +export { default as Modal, ModalButtons } from './common/Modal'; +export { default as Button, ButtonWithLoading } from './common/Button'; export { default as Input } from './common/Input'; export { default as View } from './common/View'; export { default as Text } from './common/Text'; @@ -200,44 +196,6 @@ function ButtonLink_({ export const ButtonLink = withRouter(ButtonLink_); -export const ButtonWithLoading = React.forwardRef((props, ref) => { - let { loading, children, ...buttonProps } = props; - return ( - <Button - {...buttonProps} - style={[{ position: 'relative' }, buttonProps.style]} - > - {loading && ( - <View - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: 'center', - justifyContent: 'center', - }} - > - <Loading - color="currentColor" - style={{ width: 20, height: 20, color: 'currentColor' }} - /> - </View> - )} - <View - style={{ - opacity: loading ? 0 : 1, - flexDirection: 'row', - alignItems: 'center', - }} - > - {children} - </View> - </Button> - ); -}); - export function InputWithContent({ leftContent, rightContent, @@ -628,300 +586,6 @@ export function Strong({ style, children, ...props }) { ); } -function ModalContent({ - style, - size, - noAnimation, - isCurrent, - stackIndex, - children, -}) { - let contentRef = useRef(null); - let mounted = useRef(false); - let rotateFactor = useRef(Math.random() * 10 - 5); - - useLayoutEffect(() => { - if (contentRef.current == null) { - return; - } - - function setProps() { - if (isCurrent) { - contentRef.current.style.transform = 'translateY(0px) scale(1)'; - contentRef.current.style.pointerEvents = 'auto'; - } else { - contentRef.current.style.transform = `translateY(-40px) scale(.95) rotate(${rotateFactor.current}deg)`; - contentRef.current.style.pointerEvents = 'none'; - } - } - - if (!mounted.current) { - if (noAnimation) { - contentRef.current.style.opacity = 1; - contentRef.current.style.transform = 'translateY(0px) scale(1)'; - - setTimeout(() => { - if (contentRef.current) { - contentRef.current.style.transition = - 'opacity .1s, transform .1s cubic-bezier(.42, 0, .58, 1)'; - } - }, 0); - } else { - contentRef.current.style.opacity = 0; - contentRef.current.style.transform = 'translateY(10px) scale(1)'; - - setTimeout(() => { - if (contentRef.current) { - mounted.current = true; - contentRef.current.style.transition = - 'opacity .1s, transform .1s cubic-bezier(.42, 0, .58, 1)'; - contentRef.current.style.opacity = 1; - setProps(); - } - }, 0); - } - } else { - setProps(); - } - }, [noAnimation, isCurrent, stackIndex]); - - return ( - <View - innerRef={contentRef} - style={[ - style, - size && { width: size.width, height: size.height }, - noAnimation && !isCurrent && { display: 'none' }, - ]} - > - {children} - </View> - ); -} - -export function Modal({ - title, - isCurrent, - isHidden, - size, - padding = 20, - showHeader = true, - showTitle = true, - showClose = true, - showOverlay = true, - loading = false, - noAnimation = false, - focusAfterClose = true, - stackIndex, - parent, - style, - contentStyle, - overlayStyle, - children, - onClose, -}) { - useEffect(() => { - // This deactivates any key handlers in the "app" scope. Ideally - // each modal would have a name so they could each have their own - // key handlers, but we'll do that later - let prevScope = hotkeys.getScope(); - hotkeys.setScope('modal'); - return () => hotkeys.setScope(prevScope); - }, []); - - return ( - <ReactModal - isOpen={true} - onRequestClose={onClose} - shouldCloseOnOverlayClick={false} - shouldFocusAfterRender={!global.IS_DESIGN_MODE} - shouldReturnFocusAfterClose={focusAfterClose} - appElement={document.querySelector('#root')} - parentSelector={parent && (() => parent)} - style={{ - content: { - top: 0, - left: 0, - right: 0, - bottom: 0, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - overflow: 'visible', - border: 0, - fontSize: 14, - backgroundColor: 'transparent', - padding: 0, - pointerEvents: 'auto', - ...contentStyle, - }, - overlay: { - zIndex: 3000, - backgroundColor: - showOverlay && stackIndex === 0 ? 'rgba(0, 0, 0, .1)' : 'none', - pointerEvents: showOverlay ? 'auto' : 'none', - ...overlayStyle, - ...(parent - ? { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - } - : {}), - }, - }} - > - <ModalContent - noAnimation={noAnimation} - isCurrent={isCurrent} - size={size} - style={[ - { - willChange: 'opacity, transform', - minWidth: '100%', - minHeight: 0, - borderRadius: 4, - backgroundColor: 'white', - opacity: isHidden ? 0 : 1, - [`@media (min-width: ${tokens.breakpoint_narrow})`]: { - minWidth: tokens.breakpoint_narrow, - }, - }, - styles.shadowLarge, - style, - styles.lightScrollbar, - ]} - > - {showHeader && ( - <View - style={{ - padding: 20, - position: 'relative', - flexShrink: 0, - }} - > - {showTitle && ( - <View - style={{ - color: colors.n2, - flex: 1, - alignSelf: 'center', - textAlign: 'center', - // We need to force a width for the text-overflow - // ellipses to work because we are aligning center. - // This effectively gives it a padding of 20px - width: 'calc(100% - 40px)', - }} - > - <Text - style={{ - fontSize: 25, - fontWeight: 700, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }} - > - {title} - </Text> - </View> - )} - - <View - style={{ - position: 'absolute', - right: 0, - top: 0, - bottom: 0, - justifyContent: 'center', - alignItems: 'center', - }} - > - <View - style={{ - flexDirection: 'row', - marginRight: 15, - }} - > - {showClose && ( - <Button - bare - onClick={e => onClose()} - style={{ padding: '10px 10px' }} - aria-label="Close" - > - <Delete width={10} /> - </Button> - )} - </View> - </View> - </View> - )} - <View style={{ padding, paddingTop: 0, flex: 1 }}> - {typeof children === 'function' ? children() : children} - </View> - {loading && ( - <View - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(255, 255, 255, .6)', - alignItems: 'center', - justifyContent: 'center', - zIndex: 1000, - }} - > - <Loading style={{ width: 20, height: 20 }} color={colors.n1} /> - </View> - )} - </ModalContent> - </ReactModal> - ); -} - -export function ModalButtons({ - style, - leftContent, - focusButton = false, - children, -}) { - let containerRef = useRef(null); - - useEffect(() => { - if (focusButton && containerRef.current) { - let button = containerRef.current.querySelector( - 'button:not([data-hidden])', - ); - - if (button) { - button.focus(); - } - } - }, [focusButton]); - - return ( - <View - innerRef={containerRef} - style={[ - { - flexDirection: 'row', - marginTop: 30, - }, - style, - ]} - > - {leftContent} - <View style={{ flex: 1 }} /> - {children} - </View> - ); -} - export function InlineField({ label, labelWidth, children, width, style }) { return ( <label diff --git a/packages/desktop-client/src/components/common/Button.tsx b/packages/desktop-client/src/components/common/Button.tsx index a13fe8b23f73a0276ab30d034d5d4de9b74db460..23f5653d95a2d5e8c333b27ff21757297a8ff139 100644 --- a/packages/desktop-client/src/components/common/Button.tsx +++ b/packages/desktop-client/src/components/common/Button.tsx @@ -3,14 +3,19 @@ import React from 'react'; import { css } from 'glamor'; import type { CSSProperties } from 'glamor'; +import Loading from '../../icons/AnimatedLoading'; import { styles, colors } from '../../style'; -interface ButtonProps extends React.HTMLProps<HTMLButtonElement> { +import View from './View'; + +interface ButtonProps + extends Omit<React.HTMLProps<HTMLButtonElement>, 'style'> { pressed?: boolean; primary?: boolean; hover?: boolean; bare?: boolean; disabled?: boolean; + style?: CSSProperties; hoveredStyle?: CSSProperties; activeStyle?: CSSProperties; bounce?: boolean; @@ -110,4 +115,47 @@ const Button: React.FC<ButtonProps> = React.forwardRef< }, ); +interface ButtonWithLoadingProps extends ButtonProps { + loading?: boolean; +} + +export const ButtonWithLoading: React.FC<ButtonWithLoadingProps> = + React.forwardRef((props, ref) => { + let { loading, children, ...buttonProps } = props; + return ( + <Button + {...buttonProps} + style={[{ position: 'relative' }, buttonProps.style]} + > + {loading && ( + <View + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + }} + > + <Loading + color="currentColor" + style={{ width: 20, height: 20, color: 'currentColor' }} + /> + </View> + )} + <View + style={{ + opacity: loading ? 0 : 1, + flexDirection: 'row', + alignItems: 'center', + }} + > + {children} + </View> + </Button> + ); + }); + export default Button; diff --git a/packages/desktop-client/src/components/common/Modal.tsx b/packages/desktop-client/src/components/common/Modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f82449088e4bef5559814001194007bc11fb7fbe --- /dev/null +++ b/packages/desktop-client/src/components/common/Modal.tsx @@ -0,0 +1,348 @@ +import React, { useEffect, useRef, useLayoutEffect } from 'react'; +import ReactModal from 'react-modal'; + +import type { CSSProperties } from 'glamor'; +import hotkeys from 'hotkeys-js'; + +import Loading from '../../icons/AnimatedLoading'; +import Delete from '../../icons/v0/Delete'; +import { styles, colors } from '../../style'; +import tokens from '../../tokens'; + +import Button from './Button'; +import Text from './Text'; +import View from './View'; + +export interface ModalProps { + title: string; + isCurrent?: boolean; + isHidden?: boolean; + children: React.ReactNode; + size?: { width?: number; height?: number }; + padding?: number; + showHeader?: boolean; + showTitle?: boolean; + showClose?: boolean; + showOverlay?: boolean; + loading?: boolean; + noAnimation?: boolean; + focusAfterClose?: boolean; + stackIndex?: number; + parent?: unknown; + style?: CSSProperties; + contentStyle?: CSSProperties; + overlayStyle?: CSSProperties; + onClose?: () => void; +} + +const Modal: React.FC<ModalProps> = ({ + title, + isCurrent, + isHidden, + size, + padding = 20, + showHeader = true, + showTitle = true, + showClose = true, + showOverlay = true, + loading = false, + noAnimation = false, + focusAfterClose = true, + stackIndex, + parent, + style, + contentStyle, + overlayStyle, + children, + onClose, +}) => { + useEffect(() => { + // This deactivates any key handlers in the "app" scope. Ideally + // each modal would have a name so they could each have their own + // key handlers, but we'll do that later + let prevScope = hotkeys.getScope(); + hotkeys.setScope('modal'); + return () => hotkeys.setScope(prevScope); + }, []); + + return ( + <ReactModal + isOpen={true} + onRequestClose={onClose} + shouldCloseOnOverlayClick={false} + shouldFocusAfterRender={!global.IS_DESIGN_MODE} + shouldReturnFocusAfterClose={focusAfterClose} + appElement={document.querySelector('#root')} + parentSelector={parent && (() => parent)} + style={{ + content: { + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + overflow: 'visible', + border: 0, + fontSize: 14, + backgroundColor: 'transparent', + padding: 0, + pointerEvents: 'auto', + ...contentStyle, + }, + overlay: { + zIndex: 3000, + backgroundColor: + showOverlay && stackIndex === 0 ? 'rgba(0, 0, 0, .1)' : 'none', + pointerEvents: showOverlay ? 'auto' : 'none', + ...overlayStyle, + ...(parent + ? { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + } + : {}), + }, + }} + > + <ModalContent + noAnimation={noAnimation} + isCurrent={isCurrent} + size={size} + style={[ + { + willChange: 'opacity, transform', + minWidth: '100%', + minHeight: 0, + borderRadius: 4, + backgroundColor: 'white', + opacity: isHidden ? 0 : 1, + [`@media (min-width: ${tokens.breakpoint_narrow})`]: { + minWidth: tokens.breakpoint_narrow, + }, + }, + styles.shadowLarge, + style, + styles.lightScrollbar, + ]} + > + {showHeader && ( + <View + style={{ + padding: 20, + position: 'relative', + flexShrink: 0, + }} + > + {showTitle && ( + <View + style={{ + color: colors.n2, + flex: 1, + alignSelf: 'center', + textAlign: 'center', + // We need to force a width for the text-overflow + // ellipses to work because we are aligning center. + // This effectively gives it a padding of 20px + width: 'calc(100% - 40px)', + }} + > + <Text + style={{ + fontSize: 25, + fontWeight: 700, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {title} + </Text> + </View> + )} + + <View + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }} + > + <View + style={{ + flexDirection: 'row', + marginRight: 15, + }} + > + {showClose && ( + <Button + bare + onClick={onClose} + style={{ padding: '10px 10px' }} + aria-label="Close" + > + <Delete width={10} /> + </Button> + )} + </View> + </View> + </View> + )} + <View style={{ padding, paddingTop: 0, flex: 1 }}> + {typeof children === 'function' ? children() : children} + </View> + {loading && ( + <View + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, .6)', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + }} + > + <Loading style={{ width: 20, height: 20 }} color={colors.n1} /> + </View> + )} + </ModalContent> + </ReactModal> + ); +}; + +interface ModalContentProps { + style?: CSSProperties; + size?: ModalProps['size']; + noAnimation?: boolean; + isCurrent?: boolean; + stackIndex?: number; + children: React.ReactNode; +} + +const ModalContent: React.FC<ModalContentProps> = ({ + style, + size, + noAnimation, + isCurrent, + stackIndex, + children, +}) => { + let contentRef = useRef(null); + let mounted = useRef(false); + let rotateFactor = useRef(Math.random() * 10 - 5); + + useLayoutEffect(() => { + if (contentRef.current == null) { + return; + } + + function setProps() { + if (isCurrent) { + contentRef.current.style.transform = 'translateY(0px) scale(1)'; + contentRef.current.style.pointerEvents = 'auto'; + } else { + contentRef.current.style.transform = `translateY(-40px) scale(.95) rotate(${rotateFactor.current}deg)`; + contentRef.current.style.pointerEvents = 'none'; + } + } + + if (!mounted.current) { + if (noAnimation) { + contentRef.current.style.opacity = 1; + contentRef.current.style.transform = 'translateY(0px) scale(1)'; + + setTimeout(() => { + if (contentRef.current) { + contentRef.current.style.transition = + 'opacity .1s, transform .1s cubic-bezier(.42, 0, .58, 1)'; + } + }, 0); + } else { + contentRef.current.style.opacity = 0; + contentRef.current.style.transform = 'translateY(10px) scale(1)'; + + setTimeout(() => { + if (contentRef.current) { + mounted.current = true; + contentRef.current.style.transition = + 'opacity .1s, transform .1s cubic-bezier(.42, 0, .58, 1)'; + contentRef.current.style.opacity = 1; + setProps(); + } + }, 0); + } + } else { + setProps(); + } + }, [noAnimation, isCurrent, stackIndex]); + + return ( + <View + innerRef={contentRef} + style={[ + style, + size && { width: size.width, height: size.height }, + noAnimation && !isCurrent && { display: 'none' }, + ]} + > + {children} + </View> + ); +}; + +interface ModalButtonsProps { + style?: CSSProperties; + leftContent?: React.ReactNode; + focusButton?: boolean; + children: React.ReactNode; +} + +export const ModalButtons: React.FC<ModalButtonsProps> = ({ + style, + leftContent, + focusButton = false, + children, +}) => { + let containerRef = useRef(null); + + useEffect(() => { + if (focusButton && containerRef.current) { + let button = containerRef.current.querySelector( + 'button:not([data-hidden])', + ); + + if (button) { + button.focus(); + } + } + }, [focusButton]); + + return ( + <View + innerRef={containerRef} + style={[ + { + flexDirection: 'row', + marginTop: 30, + }, + style, + ]} + > + {leftContent} + <View style={{ flex: 1 }} /> + {children} + </View> + ); +}; + +export default Modal; diff --git a/packages/desktop-client/src/components/modals/CreateAccount.js b/packages/desktop-client/src/components/modals/CreateAccount.js index 8fb8610e252e3c2013df31884a32445113d0749d..a6e616427333dd284e566a01a36f21d83a696bdb 100644 --- a/packages/desktop-client/src/components/modals/CreateAccount.js +++ b/packages/desktop-client/src/components/modals/CreateAccount.js @@ -1,23 +1,43 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { pushModal } from 'loot-core/src/client/actions/modals'; +import useNordigenStatus from '../../hooks/useNordigenStatus'; import { authorizeBank } from '../../nordigen'; import { colors } from '../../style'; import { View, Text, Modal, P, Button, ButtonWithLoading } from '../common'; export default function CreateAccount({ modalProps, syncServerStatus }) { const dispatch = useDispatch(); + const [isNordigenSetupComplete, setIsNordigenSetupComplete] = useState(null); const onConnect = () => { + if (!isNordigenSetupComplete) { + onNordigenInit(); + return; + } + authorizeBank((modal, params) => dispatch(pushModal(modal, params))); }; + const onNordigenInit = () => { + dispatch( + pushModal('nordigen-init', { + onSuccess: () => setIsNordigenSetupComplete(true), + }), + ); + }; + const onCreateLocalAccount = () => { dispatch(pushModal('add-local-account')); }; + const { configured } = useNordigenStatus(); + useEffect(() => { + setIsNordigenSetupComplete(configured); + }, [configured]); + return ( <Modal title="Add Account" {...modalProps}> {() => ( @@ -39,8 +59,15 @@ export default function CreateAccount({ modalProps, syncServerStatus }) { }} onClick={onConnect} > - Link bank account + {isNordigenSetupComplete + ? 'Link bank account' + : 'Set-up Nordigen for bank-sync'} </ButtonWithLoading> + {isNordigenSetupComplete && ( + <Button bare onClick={onNordigenInit}> + set new API secrets + </Button> + )} {syncServerStatus !== 'online' && ( <P style={{ color: colors.r5, marginTop: 5 }}> diff --git a/packages/desktop-client/src/components/modals/NordigenExternalMsg.js b/packages/desktop-client/src/components/modals/NordigenExternalMsg.js index 1537bd92a6efaaf98e7a8e3a9b61b9d1186d7171..5c052b1947ec9b602f824466ffbd1d4fa71e7890 100644 --- a/packages/desktop-client/src/components/modals/NordigenExternalMsg.js +++ b/packages/desktop-client/src/components/modals/NordigenExternalMsg.js @@ -1,7 +1,8 @@ import React, { useEffect, useState, useRef } from 'react'; -import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; +import { sendCatch } from 'loot-core/src/platform/client/fetch'; +import useNordigenStatus from '../../hooks/useNordigenStatus'; import AnimatedLoading from '../../icons/AnimatedLoading'; import { colors } from '../../style'; import { Error, Warning } from '../alerts'; @@ -50,29 +51,6 @@ function useAvailableBanks(country) { }; } -function useNordigenStatus() { - const [configured, setConfigured] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - async function fetch() { - setIsLoading(true); - - const results = await send('nordigen-status'); - - setConfigured(results.configured || false); - setIsLoading(false); - } - - fetch(); - }, [setConfigured, setIsLoading]); - - return { - configured, - isLoading, - }; -} - function renderError(error) { return ( <Error style={{ alignSelf: 'center' }}> diff --git a/packages/desktop-client/src/components/modals/NordigenInitialise.tsx b/packages/desktop-client/src/components/modals/NordigenInitialise.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4184695cc438168873a2abc28a0de88fa236a6e3 --- /dev/null +++ b/packages/desktop-client/src/components/modals/NordigenInitialise.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; + +import { send } from 'loot-core/src/platform/client/fetch'; + +import { Error } from '../alerts'; +import { ButtonWithLoading } from '../common/Button'; +import Input from '../common/Input'; +import Modal, { ModalButtons } from '../common/Modal'; +import type { ModalProps } from '../common/Modal'; +import Text from '../common/Text'; +import View from '../common/View'; +import { FormField, FormLabel } from '../forms'; + +interface NordigenInitialiseProps { + modalProps?: Partial<ModalProps>; + onSuccess: () => void; +} + +const NordigenInitialise: React.FC<NordigenInitialiseProps> = ({ + modalProps, + onSuccess, +}) => { + const [secretId, setSecretId] = useState(''); + const [secretKey, setSecretKey] = useState(''); + const [isValid, setIsValid] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async () => { + if (!secretId || !secretKey) { + setIsValid(false); + return; + } + + setIsLoading(true); + + await Promise.all([ + send('secret-set', { + name: 'nordigen_secretId', + value: secretId, + }), + send('secret-set', { + name: 'nordigen_secretKey', + value: secretKey, + }), + ]); + + onSuccess(); + modalProps.onClose(); + setIsLoading(false); + }; + + return ( + <Modal title="Set-up Nordigen" size={{ width: 300 }} {...modalProps}> + <View style={{ display: 'flex', gap: 10 }}> + <Text> + In order to enable bank-sync via Nordigen (only for EU banks) you will + need to create access credentials. This can be done by creating an + account with{' '} + <a + href="https://nordigen.com/" + target="_blank" + rel="noopener noreferrer" + > + Nordigen + </a> + . + </Text> + + <FormField> + <FormLabel title="Secret ID:" htmlFor="secret-id-field" /> + <Input + id="secret-id-field" + type="password" + value={secretId} + onUpdate={setSecretId} + onChange={() => setIsValid(true)} + /> + </FormField> + + <FormField> + <FormLabel title="Secret Key:" htmlFor="secret-key-field" /> + <Input + id="secret-key-field" + type="password" + value={secretKey} + onUpdate={setSecretKey} + onChange={() => setIsValid(true)} + /> + </FormField> + + {!isValid && ( + <Error> + It is required to provide both the secret id and secret key. + </Error> + )} + </View> + + <ModalButtons> + <ButtonWithLoading loading={isLoading} primary onClick={onSubmit}> + Save and continue + </ButtonWithLoading> + </ModalButtons> + </Modal> + ); +}; + +export default NordigenInitialise; diff --git a/packages/desktop-client/src/hooks/useNordigenStatus.ts b/packages/desktop-client/src/hooks/useNordigenStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..7915a6c8bf4cc46f8f490aa642d68973a88e17a6 --- /dev/null +++ b/packages/desktop-client/src/hooks/useNordigenStatus.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; + +import { send } from 'loot-core/src/platform/client/fetch'; + +export default function useNordigenStatus() { + const [configured, setConfigured] = useState<boolean | null>(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + async function fetch() { + setIsLoading(true); + + const results = await send('nordigen-status'); + + setConfigured(results.configured || false); + setIsLoading(false); + } + + fetch(); + }, [setConfigured, setIsLoading]); + + return { + configured, + isLoading, + }; +} diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 17a0f752cd96f0504927ce45e0ed515dc81464ff..a07047f9e7317e86ed9c2c9d92f7dfdd527c803b 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -1162,6 +1162,45 @@ handlers['accounts-sync'] = async function ({ id }) { return { errors, newTransactions, matchedTransactions, updatedAccounts }; }; +handlers['secret-set'] = async function ({ name, value }) { + let userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + try { + return await post( + getServer().BASE_SERVER + '/secret', + { + name, + value, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + } catch (error) { + console.error(error); + return { error: 'failed' }; + } + } + return { error: 'unauthorized' }; +}; + +handlers['secret-check'] = async function (name) { + let userToken = await asyncStorage.getItem('user-token'); + + if (userToken) { + try { + return await get(getServer().BASE_SERVER + '/secret/' + name, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (error) { + console.error(error); + return { error: 'failed' }; + } + } + return { error: 'unauthorized' }; +}; + handlers['nordigen-poll-web-token'] = async function ({ upgradingAccountId, requisitionId, diff --git a/packages/loot-core/src/types/main.handlers.d.ts b/packages/loot-core/src/types/main.handlers.d.ts index 0145d703ad63e39c603fca9aa21a6f4f1c752e01..49d3c00053ec221c70f03030650541a35c033ea4 100644 --- a/packages/loot-core/src/types/main.handlers.d.ts +++ b/packages/loot-core/src/types/main.handlers.d.ts @@ -190,12 +190,15 @@ export interface MainHandlers { updatedAccounts: unknown; }>; + 'secret-set': (arg: { name: string; value: string }) => Promise<null>; + 'secret-check': (arg: string) => Promise<null>; + 'nordigen-poll-web-token': (arg: { upgradingAccountId; requisitionId; }) => Promise<null>; - 'nordigen-status': () => Promise<unknown>; + 'nordigen-status': () => Promise<{ configured: boolean }>; 'nordigen-get-banks': (country) => Promise<unknown>; diff --git a/upcoming-release-notes/968.md b/upcoming-release-notes/968.md new file mode 100644 index 0000000000000000000000000000000000000000..96187e7e5c8f93aef5648aa954dd5c9fffcefdba --- /dev/null +++ b/upcoming-release-notes/968.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MatissJanis] +--- + +Nordigen: ability to configure credentials via the UI