mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-10 20:23:07 -05:00
Compare commits
7 Commits
react-quer
...
feat/scope
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6247144e | ||
|
|
e0183e5d01 | ||
|
|
fe9264151c | ||
|
|
faa8d7c222 | ||
|
|
0e840ca136 | ||
|
|
7c6284a791 | ||
|
|
e0231286ae |
@@ -59,7 +59,8 @@ export async function sync() {
|
||||
}
|
||||
|
||||
export async function runBankSync(args?: {
|
||||
accountId: APIAccountEntity['id'];
|
||||
accountId?: APIAccountEntity['id'];
|
||||
passwords?: Record<string, string>;
|
||||
}) {
|
||||
return send('api/bank-sync', args);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { resetApp } from '@desktop-client/app/appSlice';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { markPayeesDirty } from '@desktop-client/payees/payeesSlice';
|
||||
import { createAppAsyncThunk } from '@desktop-client/redux';
|
||||
@@ -377,11 +378,18 @@ function handleSyncResponse(
|
||||
|
||||
type SyncAccountsPayload = {
|
||||
id?: AccountEntity['id'] | undefined;
|
||||
passwords?: Record<string, string>;
|
||||
};
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
};
|
||||
|
||||
export const syncAccounts = createAppAsyncThunk(
|
||||
`${sliceName}/syncAccounts`,
|
||||
async ({ id }: SyncAccountsPayload, { dispatch, getState }) => {
|
||||
async ({ id, passwords }: SyncAccountsPayload, { dispatch, getState }) => {
|
||||
// Disallow two parallel sync operations
|
||||
const accountsState = getState().account;
|
||||
if (accountsState.accountsSyncing.length > 0) {
|
||||
@@ -423,13 +431,55 @@ export const syncAccounts = createAppAsyncThunk(
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
|
||||
// TODO: Force cast to AccountEntity.
|
||||
// Server is currently returning the DB model it should return the entity model instead.
|
||||
const accountsData = (await send(
|
||||
'accounts-get',
|
||||
)) as unknown as AccountEntity[];
|
||||
|
||||
// If no passwords provided, check whether any provider in scope uses encryption and show modal
|
||||
if (passwords == null) {
|
||||
const encryptionStatus = (await send('check-provider-encryption')) as
|
||||
| { goCardless?: boolean; simpleFin?: boolean; pluggyai?: boolean }
|
||||
| undefined;
|
||||
const syncSourcesInScope = new Set(
|
||||
accountsData
|
||||
.filter(
|
||||
a =>
|
||||
accountIdsToSync.includes(a.id) && a.account_sync_source != null,
|
||||
)
|
||||
.map(a => a.account_sync_source as string),
|
||||
);
|
||||
const encryptedNeeded =
|
||||
encryptionStatus && syncSourcesInScope.size > 0
|
||||
? (['goCardless', 'simpleFin', 'pluggyai'] as const).filter(
|
||||
slug => encryptionStatus[slug] && syncSourcesInScope.has(slug),
|
||||
)
|
||||
: [];
|
||||
if (encryptedNeeded.length > 0) {
|
||||
const providers = encryptedNeeded.map(slug => ({
|
||||
slug,
|
||||
displayName: PROVIDER_DISPLAY_NAMES[slug] ?? slug,
|
||||
}));
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'bank-sync-password',
|
||||
options: {
|
||||
providers,
|
||||
onSubmit: (passwordsMap: Record<string, string>) => {
|
||||
void dispatch(syncAccounts({ id, passwords: passwordsMap }));
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
|
||||
const simpleFinAccounts = accountsData.filter(
|
||||
a =>
|
||||
a.account_sync_source === 'simpleFin' &&
|
||||
@@ -446,6 +496,7 @@ export const syncAccounts = createAppAsyncThunk(
|
||||
|
||||
const res = await send('simplefin-batch-sync', {
|
||||
ids: simpleFinAccounts.map(a => a.id),
|
||||
passwords,
|
||||
});
|
||||
|
||||
for (const account of res) {
|
||||
@@ -472,6 +523,7 @@ export const syncAccounts = createAppAsyncThunk(
|
||||
// Perform sync operation
|
||||
const res = await send('accounts-bank-sync', {
|
||||
ids: [accountId],
|
||||
passwords,
|
||||
});
|
||||
|
||||
const success = handleSyncResponse(
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as monthUtils from 'loot-core/shared/months';
|
||||
import { EditSyncAccount } from './banksync/EditSyncAccount';
|
||||
import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
|
||||
import { AccountMenuModal } from './modals/AccountMenuModal';
|
||||
import { BankSyncPasswordModal } from './modals/BankSyncPasswordModal';
|
||||
import { BudgetAutomationsModal } from './modals/BudgetAutomationsModal';
|
||||
import { BudgetPageMenuModal } from './modals/BudgetPageMenuModal';
|
||||
import { CategoryAutocompleteModal } from './modals/CategoryAutocompleteModal';
|
||||
@@ -16,6 +17,7 @@ import { CategoryMenuModal } from './modals/CategoryMenuModal';
|
||||
import { CloseAccountModal } from './modals/CloseAccountModal';
|
||||
import { ConfirmCategoryDeleteModal } from './modals/ConfirmCategoryDeleteModal';
|
||||
import { ConfirmDeleteModal } from './modals/ConfirmDeleteModal';
|
||||
import { ConfirmResetCredentialsModal } from './modals/ConfirmResetCredentialsModal';
|
||||
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
|
||||
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
|
||||
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
|
||||
@@ -151,6 +153,9 @@ export function Modals() {
|
||||
case 'confirm-delete':
|
||||
return <ConfirmDeleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-reset-credentials':
|
||||
return <ConfirmResetCredentialsModal key={key} {...modal.options} />;
|
||||
|
||||
case 'copy-widget-to-dashboard':
|
||||
return <CopyWidgetToDashboardModal key={key} {...modal.options} />;
|
||||
|
||||
@@ -182,6 +187,9 @@ export function Modals() {
|
||||
case 'pluggyai-init':
|
||||
return <PluggyAiInitialiseModal key={key} {...modal.options} />;
|
||||
|
||||
case 'bank-sync-password':
|
||||
return <BankSyncPasswordModal key={key} {...modal.options} />;
|
||||
|
||||
case 'gocardless-external-msg':
|
||||
return (
|
||||
<GoCardlessExternalMsgModal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, type ReactNode } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -18,10 +18,18 @@ type AccountRowProps = {
|
||||
onHover: (id: AccountEntity['id'] | null) => void;
|
||||
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
|
||||
locale: Locale;
|
||||
renderLinkButton?: (account: AccountEntity) => ReactNode;
|
||||
};
|
||||
|
||||
export const AccountRow = memo(
|
||||
({ account, hovered, onHover, onAction, locale }: AccountRowProps) => {
|
||||
({
|
||||
account,
|
||||
hovered,
|
||||
onHover,
|
||||
onAction,
|
||||
locale,
|
||||
renderLinkButton,
|
||||
}: AccountRowProps) => {
|
||||
const backgroundFocus = hovered;
|
||||
|
||||
const lastSyncString = tsToRelativeTime(account.last_sync, locale, {
|
||||
@@ -106,9 +114,13 @@ export const AccountRow = memo(
|
||||
</Cell>
|
||||
) : (
|
||||
<Cell name="link" plain style={{ paddingRight: '10px' }}>
|
||||
<Button onPress={() => onAction(account, 'link')}>
|
||||
<Trans>Link account</Trans>
|
||||
</Button>
|
||||
{renderLinkButton ? (
|
||||
renderLinkButton(account)
|
||||
) : (
|
||||
<Button onPress={() => onAction(account, 'link')}>
|
||||
<Trans>Link account</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Cell>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
@@ -13,6 +13,7 @@ type AccountsListProps = {
|
||||
hoveredAccount?: string | null;
|
||||
onHover: (id: AccountEntity['id'] | null) => void;
|
||||
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
|
||||
renderLinkButton?: (account: AccountEntity) => ReactNode;
|
||||
};
|
||||
|
||||
export function AccountsList({
|
||||
@@ -20,6 +21,7 @@ export function AccountsList({
|
||||
hoveredAccount,
|
||||
onHover,
|
||||
onAction,
|
||||
renderLinkButton,
|
||||
}: AccountsListProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
@@ -44,6 +46,7 @@ export function AccountsList({
|
||||
onHover={onHover}
|
||||
onAction={onAction}
|
||||
locale={locale}
|
||||
renderLinkButton={renderLinkButton}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
169
packages/desktop-client/src/components/banksync/ProviderList.tsx
Normal file
169
packages/desktop-client/src/components/banksync/ProviderList.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgAdd } from '@actual-app/components/icons/v1';
|
||||
import { Menu, type MenuItem } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import {
|
||||
getInternalBankSyncProviders,
|
||||
type InternalBankSyncProvider,
|
||||
type ProviderStatusMap,
|
||||
} from './useProviderStatusMap';
|
||||
|
||||
import { Cell, Row, TableHeader } from '@desktop-client/components/table';
|
||||
|
||||
type ProviderListProps = {
|
||||
statusMap: ProviderStatusMap;
|
||||
canConfigure?: boolean;
|
||||
onConfigure: (provider: InternalBankSyncProvider) => void;
|
||||
onReset: (provider: InternalBankSyncProvider) => void;
|
||||
};
|
||||
|
||||
export function ProviderList({
|
||||
statusMap,
|
||||
canConfigure = true,
|
||||
onConfigure,
|
||||
onReset,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const providers = useMemo(() => getInternalBankSyncProviders(), []);
|
||||
|
||||
const configuredProviders = useMemo(
|
||||
() => providers.filter(p => Boolean(statusMap[p.slug]?.configured)),
|
||||
[providers, statusMap],
|
||||
);
|
||||
|
||||
const unconfiguredProviders = useMemo(
|
||||
() => providers.filter(p => !statusMap[p.slug]?.configured),
|
||||
[providers, statusMap],
|
||||
);
|
||||
|
||||
const addProviderItems: MenuItem<string>[] = useMemo(
|
||||
() =>
|
||||
unconfiguredProviders.map(p => ({
|
||||
name: p.slug,
|
||||
text: p.displayName,
|
||||
})),
|
||||
[unconfiguredProviders],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{unconfiguredProviders.length > 0 && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
isDisabled={!canConfigure}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<SvgAdd width={10} height={10} style={{ marginRight: 3 }} />
|
||||
<Trans>Add Provider</Trans>
|
||||
</Button>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
{({ close }) => (
|
||||
<Menu
|
||||
items={addProviderItems}
|
||||
onMenuSelect={itemId => {
|
||||
const provider = providers.find(
|
||||
p => p.slug === String(itemId),
|
||||
);
|
||||
if (provider) {
|
||||
onConfigure(provider);
|
||||
close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.tableContainer}>
|
||||
<TableHeader style={{ paddingLeft: 10 }}>
|
||||
<Cell
|
||||
value={t('Provider')}
|
||||
width={300}
|
||||
/>
|
||||
<Cell
|
||||
value={t('Encrypted')}
|
||||
width={100}
|
||||
/>
|
||||
<Cell value="" width="flex" />
|
||||
</TableHeader>
|
||||
{configuredProviders.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
...styles.smallText,
|
||||
color: theme.pageTextSubdued,
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: theme.tableBackground,
|
||||
padding: 10,
|
||||
borderTop: `1px solid ${theme.tableBorder}`,
|
||||
}}
|
||||
>
|
||||
<Trans>No providers enabled</Trans>
|
||||
</View>
|
||||
) : (
|
||||
configuredProviders.map(provider => (
|
||||
<Row
|
||||
key={provider.slug}
|
||||
height="auto"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
height: 40,
|
||||
paddingLeft: 10,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
collapsed
|
||||
>
|
||||
<Cell
|
||||
name="providerName"
|
||||
width={300}
|
||||
plain
|
||||
style={{ color: theme.tableText}}
|
||||
>
|
||||
{provider.displayName}
|
||||
</Cell>
|
||||
<Cell
|
||||
name="encrypted"
|
||||
width={100}
|
||||
plain
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
}}
|
||||
>
|
||||
{statusMap[provider.slug]?.encrypted
|
||||
? t('Yes')
|
||||
: t('No')}
|
||||
</Cell>
|
||||
<Cell name="reset" plain style={{ paddingRight:10, justifyContent: 'flex-end', flexDirection: 'row' }} width="flex">
|
||||
<Button
|
||||
variant="normal"
|
||||
isDisabled={!canConfigure}
|
||||
onPress={() => onReset(provider)}
|
||||
>
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
</Cell>
|
||||
</Row>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgAdd } from '@actual-app/components/icons/v1';
|
||||
import { Menu, type MenuItem } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
|
||||
import {
|
||||
getInternalBankSyncProviders,
|
||||
type InternalBankSyncProvider,
|
||||
type ProviderStatusMap,
|
||||
} from './useProviderStatusMap';
|
||||
|
||||
export function ProviderScopeButton({
|
||||
label,
|
||||
statusMap,
|
||||
onSelect,
|
||||
isDisabled = false,
|
||||
variant = 'normal',
|
||||
}: {
|
||||
label: string;
|
||||
statusMap: ProviderStatusMap;
|
||||
onSelect: (arg: {
|
||||
providerSlug: string;
|
||||
provider: InternalBankSyncProvider;
|
||||
}) => void;
|
||||
isDisabled?: boolean;
|
||||
variant?: 'normal' | 'bare';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const providers = useMemo(() => getInternalBankSyncProviders(), []);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const configured = providers.filter(p => statusMap[p.slug]?.configured);
|
||||
if (configured.length === 0) {
|
||||
return [
|
||||
{
|
||||
name: 'none',
|
||||
text: t('No providers configured'),
|
||||
disabled: true,
|
||||
},
|
||||
] as MenuItem<string>[];
|
||||
}
|
||||
return configured.map(p => ({
|
||||
name: p.slug,
|
||||
text: p.displayName,
|
||||
})) as MenuItem<string>[];
|
||||
}, [providers, statusMap, t]);
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant={variant}
|
||||
isDisabled={isDisabled}
|
||||
style={variant === 'bare' ? { padding: 0 } : undefined}
|
||||
>
|
||||
{variant === 'bare' && (
|
||||
<SvgAdd width={10} height={10} style={{ marginRight: 3 }} />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
{({ close }) => (
|
||||
<Menu
|
||||
items={items}
|
||||
onMenuSelect={itemId => {
|
||||
const providerSlug = String(itemId);
|
||||
if (providerSlug === 'none') return;
|
||||
const provider = providers.find(p => p.slug === providerSlug);
|
||||
if (provider) {
|
||||
onSelect({ providerSlug, provider });
|
||||
close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type BankSyncProviders,
|
||||
@@ -13,12 +15,22 @@ import {
|
||||
|
||||
import { AccountsHeader } from './AccountsHeader';
|
||||
import { AccountsList } from './AccountsList';
|
||||
import { ProviderList } from './ProviderList';
|
||||
import { ProviderScopeButton } from './ProviderScopeButton';
|
||||
import { useProviderStatusMap } from './useProviderStatusMap';
|
||||
|
||||
import { useAuth } from '@desktop-client/auth/AuthProvider';
|
||||
import { Permissions } from '@desktop-client/auth/types';
|
||||
import { Warning } from '@desktop-client/components/alerts';
|
||||
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
|
||||
import { Page } from '@desktop-client/components/Page';
|
||||
import { useMultiuserEnabled } from '@desktop-client/components/ServerContext';
|
||||
import { authorizeBank } from '@desktop-client/gocardless';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type SyncProviders = BankSyncProviders | 'unlinked';
|
||||
@@ -45,6 +57,27 @@ export function BankSync() {
|
||||
const accounts = useAccounts();
|
||||
const dispatch = useDispatch();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const [budgetId] = useMetadataPref('id');
|
||||
const { hasPermission } = useAuth();
|
||||
const multiuserEnabled = useMultiuserEnabled();
|
||||
const canConfigureProviders =
|
||||
!multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR);
|
||||
|
||||
const {
|
||||
statusMap,
|
||||
refetch: refetchProviderStatuses,
|
||||
providers,
|
||||
} = useProviderStatusMap({ fileId: budgetId ?? undefined });
|
||||
|
||||
const hasConfiguredProviders = useMemo(
|
||||
() => providers.some(p => Boolean(statusMap[p.slug]?.configured)),
|
||||
[providers, statusMap],
|
||||
);
|
||||
|
||||
const openAccounts = useMemo(
|
||||
() => accounts.filter(a => !a.closed),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const [hoveredAccount, setHoveredAccount] = useState<
|
||||
AccountEntity['id'] | null
|
||||
@@ -78,9 +111,9 @@ export function BankSync() {
|
||||
);
|
||||
}, [accounts]);
|
||||
|
||||
const onAction = async (account: AccountEntity, action: 'link' | 'edit') => {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
const onAction = useCallback(
|
||||
async (account: AccountEntity, action: 'link' | 'edit') => {
|
||||
if (action === 'edit') {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
@@ -91,26 +124,303 @@ export function BankSync() {
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'link':
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'add-account',
|
||||
options: { upgradingAccountId: account.id },
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const onHover = useCallback((id: AccountEntity['id'] | null) => {
|
||||
setHoveredAccount(id);
|
||||
}, []);
|
||||
|
||||
const configureProvider = useCallback(
|
||||
(provider: { slug: string; displayName: string }) => {
|
||||
const fileId = budgetId ?? '';
|
||||
if (!fileId) return;
|
||||
if (provider.slug === 'goCardless') {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'gocardless-init',
|
||||
options: {
|
||||
onSuccess: () => refetchProviderStatuses(),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (provider.slug === 'simpleFin') {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => refetchProviderStatuses(),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (provider.slug === 'pluggyai') {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'pluggyai-init',
|
||||
options: {
|
||||
onSuccess: () => refetchProviderStatuses(),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, refetchProviderStatuses, budgetId],
|
||||
);
|
||||
|
||||
const resetProvider = useCallback(
|
||||
(provider: { slug: string; displayName: string }) => {
|
||||
const fileId = budgetId ?? '';
|
||||
const message = t(
|
||||
'Are you sure you want to reset the {{provider}} credentials? You will need to set them up again to use bank sync.',
|
||||
{ provider: provider.displayName },
|
||||
);
|
||||
|
||||
const doReset = async () => {
|
||||
if (provider.slug === 'goCardless') {
|
||||
await send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: null,
|
||||
fileId,
|
||||
});
|
||||
await send('secret-set', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: null,
|
||||
fileId,
|
||||
});
|
||||
} else if (provider.slug === 'simpleFin') {
|
||||
await send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: null,
|
||||
fileId,
|
||||
});
|
||||
await send('secret-set', {
|
||||
name: 'simplefin_accessKey',
|
||||
value: null,
|
||||
fileId,
|
||||
});
|
||||
} else if (provider.slug === 'pluggyai') {
|
||||
await send('secret-set', {
|
||||
name: 'pluggyai_clientId',
|
||||
value: null,
|
||||
fileId,
|
||||
});
|
||||
await send('secret-set', {
|
||||
name: 'pluggyai_clientSecret',
|
||||
value: null,
|
||||
fileId,
|
||||
});
|
||||
await send('secret-set', {
|
||||
name: 'pluggyai_itemIds',
|
||||
value: null,
|
||||
fileId,
|
||||
});
|
||||
}
|
||||
refetchProviderStatuses();
|
||||
};
|
||||
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-reset-credentials',
|
||||
options: { message, onConfirm: doReset },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, budgetId, refetchProviderStatuses, t],
|
||||
);
|
||||
|
||||
const openProviderAccounts = useCallback(
|
||||
async ({
|
||||
providerSlug,
|
||||
upgradingAccountId: upgradingId,
|
||||
passwords,
|
||||
}: {
|
||||
providerSlug: string;
|
||||
upgradingAccountId?: string;
|
||||
passwords?: Record<string, string>;
|
||||
}) => {
|
||||
const fileId = budgetId ?? undefined;
|
||||
if (!fileId) return;
|
||||
|
||||
const password = passwords?.[providerSlug];
|
||||
|
||||
if (providerSlug === 'goCardless') {
|
||||
authorizeBank(dispatch, fileId, password);
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerSlug === 'simpleFin') {
|
||||
try {
|
||||
const results = await send('simplefin-accounts', {
|
||||
fileId,
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
}
|
||||
const newAccounts = (results.accounts ?? []).map(
|
||||
(oldAccount: {
|
||||
id: string;
|
||||
name: string;
|
||||
org: { name: string; domain: string; id: string };
|
||||
balance: number;
|
||||
}) => ({
|
||||
account_id: oldAccount.id,
|
||||
name: oldAccount.name,
|
||||
institution: oldAccount.org.name,
|
||||
orgDomain: oldAccount.org.domain,
|
||||
orgId: oldAccount.org.id,
|
||||
balance: oldAccount.balance,
|
||||
}),
|
||||
);
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts: newAccounts,
|
||||
syncSource: 'simpleFin',
|
||||
upgradingAccountId: upgradingId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Error fetching accounts'),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
timeout: 5000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerSlug === 'pluggyai') {
|
||||
try {
|
||||
const results = await send('pluggyai-accounts', {
|
||||
...(fileId ? { fileId } : {}),
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
if (results?.error_code) {
|
||||
throw new Error(results.reason);
|
||||
}
|
||||
if (results && 'error' in results) {
|
||||
throw new Error((results as { error: string }).error);
|
||||
}
|
||||
type PluggyAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
taxNumber?: string;
|
||||
owner?: string;
|
||||
balance: number;
|
||||
bankData?: {
|
||||
automaticallyInvestedBalance: number;
|
||||
closingBalance: number;
|
||||
};
|
||||
};
|
||||
const accountsList =
|
||||
(results as { accounts?: PluggyAccount[] }).accounts ?? [];
|
||||
const newAccounts = accountsList.map((oldAccount: PluggyAccount) => ({
|
||||
account_id: oldAccount.id,
|
||||
name: `${oldAccount.name.trim()} - ${oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner}`,
|
||||
institution: oldAccount.name,
|
||||
orgDomain: null,
|
||||
orgId: oldAccount.id,
|
||||
balance:
|
||||
oldAccount.type === 'BANK' && oldAccount.bankData
|
||||
? oldAccount.bankData.automaticallyInvestedBalance +
|
||||
oldAccount.bankData.closingBalance
|
||||
: oldAccount.balance,
|
||||
}));
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts: newAccounts,
|
||||
syncSource: 'pluggyai',
|
||||
upgradingAccountId: upgradingId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Error when trying to contact Pluggy.ai'),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
timeout: 5000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, budgetId, t],
|
||||
);
|
||||
|
||||
const handleOpenProviderAccounts = useCallback(
|
||||
({
|
||||
providerSlug,
|
||||
provider,
|
||||
upgradingAccountId,
|
||||
}: {
|
||||
providerSlug: string;
|
||||
provider: { slug: string; displayName: string };
|
||||
upgradingAccountId?: string;
|
||||
}) => {
|
||||
if (statusMap[providerSlug]?.encrypted) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'bank-sync-password',
|
||||
options: {
|
||||
providers: [provider],
|
||||
onSubmit: (passwords: Record<string, string>) => {
|
||||
openProviderAccounts({
|
||||
providerSlug,
|
||||
upgradingAccountId,
|
||||
passwords,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
openProviderAccounts({
|
||||
providerSlug,
|
||||
upgradingAccountId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[dispatch, statusMap, openProviderAccounts],
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
header={t('Bank Sync')}
|
||||
@@ -121,35 +431,98 @@ export function BankSync() {
|
||||
}}
|
||||
>
|
||||
<View style={{ marginTop: '1em' }}>
|
||||
{accounts.length === 0 && (
|
||||
<Text style={{ fontSize: '1.1rem' }}>
|
||||
<Trans>
|
||||
To use the bank syncing features, you must first add an account.
|
||||
</Trans>
|
||||
<View style={{ gap: 12, marginBottom: 24 }}>
|
||||
<Text style={{ fontWeight: 600, fontSize: 18 }}>
|
||||
<Trans>Providers</Trans>
|
||||
</Text>
|
||||
<ProviderList
|
||||
statusMap={statusMap}
|
||||
canConfigure={canConfigureProviders}
|
||||
onConfigure={configureProvider}
|
||||
onReset={resetProvider}
|
||||
/>
|
||||
{!canConfigureProviders && (
|
||||
<Warning>
|
||||
<Trans>
|
||||
You don't have the required permissions to configure bank
|
||||
sync providers. Please contact an Admin to configure them.
|
||||
</Trans>
|
||||
</Warning>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{hasConfiguredProviders && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<ProviderScopeButton
|
||||
label={t('Add bank sync account')}
|
||||
statusMap={statusMap}
|
||||
variant="bare"
|
||||
isDisabled={false}
|
||||
onSelect={({ providerSlug, provider }) =>
|
||||
handleOpenProviderAccounts({ providerSlug, provider })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{openAccounts.length === 0 ? (
|
||||
<Text style={{ color: theme.pageTextSubdued, fontStyle: 'italic' }}>
|
||||
<Trans>No bank accounts to link to a provider</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
Object.entries(groupedAccounts).map(
|
||||
([syncProvider, accountsList]) => {
|
||||
return (
|
||||
<View key={syncProvider} style={{ minHeight: 'initial' }}>
|
||||
{Object.keys(groupedAccounts).length > 1 && (
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
margin: '.5em 0',
|
||||
}}
|
||||
>
|
||||
{syncSourceReadable[syncProvider as SyncProviders]}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.tableContainer}>
|
||||
<AccountsHeader unlinked={syncProvider === 'unlinked'} />
|
||||
<AccountsList
|
||||
accounts={accountsList}
|
||||
hoveredAccount={hoveredAccount}
|
||||
onHover={onHover}
|
||||
onAction={onAction}
|
||||
renderLinkButton={
|
||||
hasConfiguredProviders
|
||||
? account => (
|
||||
<ProviderScopeButton
|
||||
label={t('Link account')}
|
||||
statusMap={statusMap}
|
||||
isDisabled={false}
|
||||
onSelect={({ providerSlug, provider }) =>
|
||||
handleOpenProviderAccounts({
|
||||
providerSlug,
|
||||
provider,
|
||||
upgradingAccountId: account.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
)
|
||||
)}
|
||||
{Object.entries(groupedAccounts).map(([syncProvider, accounts]) => {
|
||||
return (
|
||||
<View key={syncProvider} style={{ minHeight: 'initial' }}>
|
||||
{Object.keys(groupedAccounts).length > 1 && (
|
||||
<Text
|
||||
style={{ fontWeight: 500, fontSize: 20, margin: '.5em 0' }}
|
||||
>
|
||||
{syncSourceReadable[syncProvider as SyncProviders]}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.tableContainer}>
|
||||
<AccountsHeader unlinked={syncProvider === 'unlinked'} />
|
||||
<AccountsList
|
||||
accounts={accounts}
|
||||
hoveredAccount={hoveredAccount}
|
||||
onHover={onHover}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||
|
||||
export type InternalBankSyncProvider = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
const INTERNAL_PROVIDERS: InternalBankSyncProvider[] = [
|
||||
{ slug: 'goCardless', displayName: 'GoCardless' },
|
||||
{ slug: 'simpleFin', displayName: 'SimpleFIN' },
|
||||
{ slug: 'pluggyai', displayName: 'Pluggy.ai' },
|
||||
];
|
||||
|
||||
export function getInternalBankSyncProviders(): InternalBankSyncProvider[] {
|
||||
return INTERNAL_PROVIDERS;
|
||||
}
|
||||
|
||||
type ScopeStatus = {
|
||||
configured: boolean;
|
||||
encrypted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ProviderStatusMap = Record<string, ScopeStatus>;
|
||||
|
||||
async function getProviderStatus(
|
||||
slug: string,
|
||||
fileId: string,
|
||||
): Promise<ScopeStatus> {
|
||||
try {
|
||||
if (slug === 'pluggyai') {
|
||||
const result = (await send('pluggyai-status', { fileId })) as {
|
||||
configured?: boolean;
|
||||
encrypted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
return {
|
||||
configured: Boolean(result?.configured),
|
||||
encrypted: Boolean(result?.encrypted),
|
||||
error: result?.error,
|
||||
};
|
||||
}
|
||||
if (slug === 'goCardless') {
|
||||
const result = (await send('gocardless-status', { fileId })) as {
|
||||
configured?: boolean;
|
||||
encrypted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
return {
|
||||
configured: Boolean(result?.configured),
|
||||
encrypted: Boolean(result?.encrypted),
|
||||
error: result?.error,
|
||||
};
|
||||
}
|
||||
if (slug === 'simpleFin') {
|
||||
const result = (await send('simplefin-status', { fileId })) as {
|
||||
configured?: boolean;
|
||||
encrypted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
return {
|
||||
configured: Boolean(result?.configured),
|
||||
encrypted: Boolean(result?.encrypted),
|
||||
error: result?.error,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
configured: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
return { configured: false };
|
||||
}
|
||||
|
||||
export function useProviderStatusMap({
|
||||
fileId,
|
||||
}: {
|
||||
fileId?: string;
|
||||
} = {}) {
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const providers = useMemo(() => getInternalBankSyncProviders(), []);
|
||||
|
||||
const [statusMap, setStatusMap] = useState<ProviderStatusMap>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refetchToken, setRefetchToken] = useState(0);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setRefetchToken(x => x + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncServerStatus !== 'online' || !fileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fid = fileId;
|
||||
let didCancel = false;
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
providers.map(async provider => {
|
||||
const result = await getProviderStatus(provider.slug, fid);
|
||||
return [provider.slug, result] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!didCancel) {
|
||||
setStatusMap(Object.fromEntries(entries));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!didCancel) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!didCancel) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, [fileId, syncServerStatus, refetchToken, providers]);
|
||||
|
||||
return { statusMap, isLoading, error, refetch, providers };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Error } from '@desktop-client/components/alerts';
|
||||
import {
|
||||
Modal,
|
||||
ModalButtons,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
type BankSyncPasswordModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'bank-sync-password' }
|
||||
>['options'];
|
||||
|
||||
export function BankSyncPasswordModal({
|
||||
providers,
|
||||
onSubmit: onSubmitProp,
|
||||
}: BankSyncPasswordModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [passwords, setPasswords] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = (close: () => void) => {
|
||||
const missing = providers.filter(p => !passwords[p.slug]?.trim?.());
|
||||
if (missing.length > 0) {
|
||||
setError(
|
||||
t('Please enter the encryption password for each provider listed.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of providers) {
|
||||
map[p.slug] = passwords[p.slug]?.trim() ?? '';
|
||||
}
|
||||
onSubmitProp(map);
|
||||
setIsLoading(false);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal name="bank-sync-password" containerProps={{ style: { width: 400 } }}>
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Bank sync encryption passwords')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 12 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
The following providers have encrypted secrets. Enter the
|
||||
encryption password for each to continue syncing.
|
||||
</Trans>
|
||||
</Text>
|
||||
{providers.map(provider => (
|
||||
<FormField key={provider.slug}>
|
||||
<FormLabel
|
||||
title={`${provider.displayName}:`}
|
||||
htmlFor={`bank-sync-pwd-${provider.slug}`}
|
||||
/>
|
||||
<Input
|
||||
id={`bank-sync-pwd-${provider.slug}`}
|
||||
type="password"
|
||||
value={passwords[provider.slug] ?? ''}
|
||||
onChangeValue={value => {
|
||||
setPasswords(prev => ({ ...prev, [provider.slug]: value }));
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
))}
|
||||
{error != null && <Error>{error}</Error>}
|
||||
</View>
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
isLoading={isLoading}
|
||||
onPress={() => onSubmit(close)}
|
||||
>
|
||||
<Trans>Continue sync</Trans>
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
type ConfirmResetCredentialsModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'confirm-reset-credentials' }
|
||||
>['options'];
|
||||
|
||||
export function ConfirmResetCredentialsModal({
|
||||
message,
|
||||
onConfirm,
|
||||
}: ConfirmResetCredentialsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal name="confirm-reset-credentials">
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Reset credentials')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Paragraph>{message}</Paragraph>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Button onPress={close}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
onConfirm();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
import { useMultiuserEnabled } from '@desktop-client/components/ServerContext';
|
||||
import { authorizeBank } from '@desktop-client/gocardless';
|
||||
import { useGoCardlessStatus } from '@desktop-client/hooks/useGoCardlessStatus';
|
||||
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePluggyAiStatus } from '@desktop-client/hooks/usePluggyAiStatus';
|
||||
import { useSimpleFinStatus } from '@desktop-client/hooks/useSimpleFinStatus';
|
||||
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||
@@ -45,9 +48,13 @@ export function CreateAccountModal({
|
||||
upgradingAccountId,
|
||||
}: CreateAccountModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [budgetId] = useMetadataPref('id');
|
||||
const navigate = useNavigate();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const dispatch = useDispatch();
|
||||
const fileId = budgetId ?? '';
|
||||
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
@@ -67,9 +74,9 @@ export function CreateAccountModal({
|
||||
}
|
||||
|
||||
if (upgradingAccountId == null) {
|
||||
authorizeBank(dispatch);
|
||||
authorizeBank(dispatch, fileId);
|
||||
} else {
|
||||
authorizeBank(dispatch);
|
||||
authorizeBank(dispatch, fileId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,7 +93,7 @@ export function CreateAccountModal({
|
||||
setLoadingSimpleFinAccounts(true);
|
||||
|
||||
try {
|
||||
const results = await send('simplefin-accounts');
|
||||
const results = await send('simplefin-accounts', { fileId });
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
}
|
||||
@@ -134,6 +141,7 @@ export function CreateAccountModal({
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => setIsSimpleFinSetupComplete(true),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -150,7 +158,7 @@ export function CreateAccountModal({
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await send('pluggyai-accounts');
|
||||
const results = await send('pluggyai-accounts', { fileId });
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
} else if ('error' in results) {
|
||||
@@ -212,6 +220,7 @@ export function CreateAccountModal({
|
||||
name: 'pluggyai-init',
|
||||
options: {
|
||||
onSuccess: () => setIsPluggyAiSetupComplete(true),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -226,6 +235,7 @@ export function CreateAccountModal({
|
||||
name: 'gocardless-init',
|
||||
options: {
|
||||
onSuccess: () => setIsGoCardlessSetupComplete(true),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -239,6 +249,7 @@ export function CreateAccountModal({
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => setIsSimpleFinSetupComplete(true),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -252,6 +263,7 @@ export function CreateAccountModal({
|
||||
name: 'pluggyai-init',
|
||||
options: {
|
||||
onSuccess: () => setIsPluggyAiSetupComplete(true),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -262,10 +274,12 @@ export function CreateAccountModal({
|
||||
send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: null,
|
||||
fileId,
|
||||
}).then(() => {
|
||||
send('secret-set', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: null,
|
||||
fileId,
|
||||
}).then(() => {
|
||||
setIsGoCardlessSetupComplete(false);
|
||||
});
|
||||
@@ -276,10 +290,12 @@ export function CreateAccountModal({
|
||||
send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: null,
|
||||
fileId,
|
||||
}).then(() => {
|
||||
send('secret-set', {
|
||||
name: 'simplefin_accessKey',
|
||||
value: null,
|
||||
fileId,
|
||||
}).then(() => {
|
||||
setIsSimpleFinSetupComplete(false);
|
||||
});
|
||||
@@ -290,14 +306,17 @@ export function CreateAccountModal({
|
||||
send('secret-set', {
|
||||
name: 'pluggyai_clientId',
|
||||
value: null,
|
||||
fileId,
|
||||
}).then(() => {
|
||||
send('secret-set', {
|
||||
name: 'pluggyai_clientSecret',
|
||||
value: null,
|
||||
fileId,
|
||||
}).then(() => {
|
||||
send('secret-set', {
|
||||
name: 'pluggyai_itemIds',
|
||||
value: null,
|
||||
fileId,
|
||||
}).then(() => {
|
||||
setIsPluggyAiSetupComplete(false);
|
||||
});
|
||||
@@ -309,17 +328,17 @@ export function CreateAccountModal({
|
||||
dispatch(pushModal({ modal: { name: 'add-local-account' } }));
|
||||
};
|
||||
|
||||
const { configuredGoCardless } = useGoCardlessStatus();
|
||||
const { configuredGoCardless } = useGoCardlessStatus(fileId || undefined);
|
||||
useEffect(() => {
|
||||
setIsGoCardlessSetupComplete(configuredGoCardless);
|
||||
}, [configuredGoCardless]);
|
||||
|
||||
const { configuredSimpleFin } = useSimpleFinStatus();
|
||||
const { configuredSimpleFin } = useSimpleFinStatus(fileId || undefined);
|
||||
useEffect(() => {
|
||||
setIsSimpleFinSetupComplete(configuredSimpleFin);
|
||||
}, [configuredSimpleFin]);
|
||||
|
||||
const { configuredPluggyAi } = usePluggyAiStatus();
|
||||
const { configuredPluggyAi } = usePluggyAiStatus(fileId || undefined);
|
||||
useEffect(() => {
|
||||
setIsPluggyAiSetupComplete(configuredPluggyAi);
|
||||
}, [configuredPluggyAi]);
|
||||
@@ -382,200 +401,229 @@ export function CreateAccountModal({
|
||||
<>
|
||||
{canSetSecrets && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectGoCardless}
|
||||
>
|
||||
{isGoCardlessSetupComplete
|
||||
? t('Link bank account with GoCardless')
|
||||
: t('Set up GoCardless for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isGoCardlessSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('GoCardless menu')}
|
||||
{isNarrowWidth ? (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectGoCardless}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
{isGoCardlessSetupComplete
|
||||
? t('Link bank account with GoCardless')
|
||||
: t('Set up GoCardless for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isGoCardlessSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('GoCardless menu')}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onGoCardlessReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset GoCardless credentials'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>European</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. GoCardless
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: '18px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
isLoading={loadingSimpleFinAccounts}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectSimpleFin}
|
||||
>
|
||||
{isSimpleFinSetupComplete
|
||||
? t('Link bank account with SimpleFIN')
|
||||
: t('Set up SimpleFIN for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isSimpleFinSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('SimpleFIN menu')}
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onGoCardlessReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t(
|
||||
'Reset GoCardless credentials',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>European</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. GoCardless
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: '18px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
isLoading={loadingSimpleFinAccounts}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectSimpleFin}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onSimpleFinReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset SimpleFIN credentials'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>North American</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. SimpleFIN
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
{isSimpleFinSetupComplete
|
||||
? t('Link bank account with SimpleFIN')
|
||||
: t('Set up SimpleFIN for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isSimpleFinSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('SimpleFIN menu')}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onSimpleFinReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t(
|
||||
'Reset SimpleFIN credentials',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>North American</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. SimpleFIN
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectPluggyAi}
|
||||
>
|
||||
{isPluggyAiSetupComplete
|
||||
? t('Link bank account with Pluggy.ai')
|
||||
: t('Set up Pluggy.ai for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isPluggyAiSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Pluggy.ai menu')}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectPluggyAi}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
{isPluggyAiSetupComplete
|
||||
? t('Link bank account with Pluggy.ai')
|
||||
: t('Set up Pluggy.ai for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isPluggyAiSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Pluggy.ai menu')}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onPluggyAiReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset Pluggy.ai credentials'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>Brazilian</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. Pluggy.ai
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onPluggyAiReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t(
|
||||
'Reset Pluggy.ai credentials',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>Brazilian</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. Pluggy.ai
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
Link bank accounts to automatically download
|
||||
transactions. Set up one or more providers on the
|
||||
Bank Sync page, then add and link accounts.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => {
|
||||
close();
|
||||
navigate('/bank-sync');
|
||||
}}
|
||||
>
|
||||
<Trans>Bank Sync</Trans>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
function useAvailableBanks(country: string) {
|
||||
function useAvailableBanks(country: string, fileId?: string) {
|
||||
const [banks, setBanks] = useState<GoCardlessInstitution[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
@@ -50,7 +50,8 @@ function useAvailableBanks(country: string) {
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { data, error } = await sendCatch('gocardless-get-banks', country);
|
||||
const payload = fileId ? { country, fileId } : country;
|
||||
const { data, error } = await sendCatch('gocardless-get-banks', payload);
|
||||
|
||||
if (error || !Array.isArray(data)) {
|
||||
setIsError(true);
|
||||
@@ -63,7 +64,7 @@ function useAvailableBanks(country: string) {
|
||||
}
|
||||
|
||||
fetch();
|
||||
}, [setBanks, setIsLoading, country]);
|
||||
}, [country, fileId]);
|
||||
|
||||
return {
|
||||
data: banks,
|
||||
@@ -97,6 +98,7 @@ export function GoCardlessExternalMsgModal({
|
||||
onMoveExternal,
|
||||
onSuccess,
|
||||
onClose,
|
||||
fileId,
|
||||
}: GoCardlessExternalMsgModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -129,11 +131,11 @@ export function GoCardlessExternalMsgModal({
|
||||
data: bankOptions,
|
||||
isLoading: isBankOptionsLoading,
|
||||
isError: isBankOptionError,
|
||||
} = useAvailableBanks(country);
|
||||
} = useAvailableBanks(country ?? '', fileId);
|
||||
const {
|
||||
configuredGoCardless: isConfigured,
|
||||
isLoading: isConfigurationLoading,
|
||||
} = useGoCardlessStatus();
|
||||
} = useGoCardlessStatus(fileId);
|
||||
|
||||
async function onJump() {
|
||||
setError(null);
|
||||
@@ -161,12 +163,14 @@ export function GoCardlessExternalMsgModal({
|
||||
}
|
||||
|
||||
const onGoCardlessInit = () => {
|
||||
if (!fileId) return;
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'gocardless-init',
|
||||
options: {
|
||||
onSuccess: () => setIsGoCardlessSetupComplete(true),
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { Toggle } from '@actual-app/components/toggle';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
@@ -22,6 +23,19 @@ import {
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
function generateSalt(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
const cryptoObj =
|
||||
typeof globalThis !== 'undefined' &&
|
||||
(globalThis as { crypto?: Crypto }).crypto;
|
||||
if (cryptoObj?.getRandomValues) {
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < 32; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
type GoCardlessInitialiseModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'gocardless-init' }
|
||||
@@ -29,10 +43,14 @@ type GoCardlessInitialiseModalProps = Extract<
|
||||
|
||||
export const GoCardlessInitialiseModal = ({
|
||||
onSuccess,
|
||||
fileId,
|
||||
}: GoCardlessInitialiseModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [secretId, setSecretId] = useState('');
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [encryptSecrets, setEncryptSecrets] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(
|
||||
@@ -47,30 +65,76 @@ export const GoCardlessInitialiseModal = ({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (encryptSecrets) {
|
||||
if (!password || password.length < 1) {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
t('Encryption password is required when encryption is enabled.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setIsValid(false);
|
||||
setError(t('Password and confirmation do not match.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
let { error, reason } =
|
||||
(await send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: secretId,
|
||||
})) || {};
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(error, reason));
|
||||
return;
|
||||
if (encryptSecrets && password) {
|
||||
const salt = generateSalt();
|
||||
let { error: err, reason } =
|
||||
(await send('secret-set-encrypted', {
|
||||
name: 'gocardless_secretId',
|
||||
value: secretId,
|
||||
password,
|
||||
salt,
|
||||
fileId,
|
||||
})) || {};
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
}
|
||||
({ error: err, reason } =
|
||||
(await send('secret-set-encrypted', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: secretKey,
|
||||
password,
|
||||
salt,
|
||||
fileId,
|
||||
})) || {});
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
({ error, reason } =
|
||||
let { error: err, reason } =
|
||||
(await send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: secretId,
|
||||
fileId,
|
||||
})) || {};
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
}
|
||||
({ error: err, reason } =
|
||||
(await send('secret-set', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: secretKey,
|
||||
fileId,
|
||||
})) || {});
|
||||
if (error) {
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(error, reason));
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -134,6 +198,61 @@ export const GoCardlessInitialiseModal = ({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Toggle
|
||||
id="encrypt-gocardless"
|
||||
isOn={encryptSecrets}
|
||||
onToggle={setEncryptSecrets}
|
||||
/>
|
||||
<FormLabel
|
||||
title={t('Encrypt secrets')}
|
||||
htmlFor="encrypt-gocardless"
|
||||
/>
|
||||
</View>
|
||||
</FormField>
|
||||
|
||||
{encryptSecrets && (
|
||||
<>
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Encryption password:')}
|
||||
htmlFor="encrypt-password-field"
|
||||
/>
|
||||
<Input
|
||||
id="encrypt-password-field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChangeValue={value => {
|
||||
setPassword(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Confirm password:')}
|
||||
htmlFor="encrypt-confirm-field"
|
||||
/>
|
||||
<Input
|
||||
id="encrypt-confirm-field"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChangeValue={value => {
|
||||
setConfirmPassword(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isValid && <Error>{error}</Error>}
|
||||
</View>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { Toggle } from '@actual-app/components/toggle';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
@@ -22,6 +23,19 @@ import {
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
function generateSalt(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
const cryptoObj =
|
||||
typeof globalThis !== 'undefined' &&
|
||||
(globalThis as { crypto?: Crypto }).crypto;
|
||||
if (cryptoObj?.getRandomValues) {
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < 32; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
type PluggyAiInitialiseProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'pluggyai-init' }
|
||||
@@ -29,11 +43,15 @@ type PluggyAiInitialiseProps = Extract<
|
||||
|
||||
export const PluggyAiInitialiseModal = ({
|
||||
onSuccess,
|
||||
fileId,
|
||||
}: PluggyAiInitialiseProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [itemIds, setItemIds] = useState('');
|
||||
const [encryptSecrets, setEncryptSecrets] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(
|
||||
@@ -52,44 +70,88 @@ export const PluggyAiInitialiseModal = ({
|
||||
);
|
||||
return;
|
||||
}
|
||||
const wantsEncryption =
|
||||
password?.trim() &&
|
||||
confirmPassword?.trim() &&
|
||||
password.trim() === confirmPassword.trim();
|
||||
if (encryptSecrets) {
|
||||
if (!wantsEncryption) {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
t('Encryption password is required when encryption is enabled.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (password?.trim() !== confirmPassword?.trim()) {
|
||||
setIsValid(false);
|
||||
setError(t('Password and confirmation do not match.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
let { error, reason } =
|
||||
(await send('secret-set', {
|
||||
name: 'pluggyai_clientId',
|
||||
value: clientId,
|
||||
})) || {};
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(error, reason));
|
||||
return;
|
||||
if (wantsEncryption) {
|
||||
for (const [name, value] of [
|
||||
['pluggyai_clientId', clientId],
|
||||
['pluggyai_clientSecret', clientSecret],
|
||||
['pluggyai_itemIds', itemIds],
|
||||
] as const) {
|
||||
const result =
|
||||
(await send('secret-set-encrypted', {
|
||||
name,
|
||||
value,
|
||||
password: password.trim(),
|
||||
fileId,
|
||||
})) || {};
|
||||
const { error: err, reason } = result;
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
({ error, reason } =
|
||||
let result =
|
||||
(await send('secret-set', {
|
||||
name: 'pluggyai_clientId',
|
||||
value: clientId,
|
||||
fileId,
|
||||
})) || {};
|
||||
let { error: err, reason } = result;
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
}
|
||||
|
||||
result =
|
||||
(await send('secret-set', {
|
||||
name: 'pluggyai_clientSecret',
|
||||
value: clientSecret,
|
||||
})) || {});
|
||||
if (error) {
|
||||
fileId,
|
||||
})) || {};
|
||||
({ error: err, reason } = result);
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(error, reason));
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
} else {
|
||||
({ error, reason } =
|
||||
(await send('secret-set', {
|
||||
name: 'pluggyai_itemIds',
|
||||
value: itemIds,
|
||||
})) || {});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(error, reason));
|
||||
return;
|
||||
}
|
||||
result =
|
||||
(await send('secret-set', {
|
||||
name: 'pluggyai_itemIds',
|
||||
value: itemIds,
|
||||
fileId,
|
||||
})) || {};
|
||||
({ error: err, reason } = result);
|
||||
if (err) {
|
||||
setIsLoading(false);
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +166,7 @@ export const PluggyAiInitialiseModal = ({
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Set-up Pluggy.ai')}
|
||||
title={t('Set up Pluggy.ai')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
@@ -172,6 +234,61 @@ export const PluggyAiInitialiseModal = ({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Toggle
|
||||
id="encrypt-pluggyai"
|
||||
isOn={encryptSecrets}
|
||||
onToggle={setEncryptSecrets}
|
||||
/>
|
||||
<FormLabel
|
||||
title={t('Encrypt secrets')}
|
||||
htmlFor="encrypt-pluggyai"
|
||||
/>
|
||||
</View>
|
||||
</FormField>
|
||||
|
||||
{encryptSecrets && (
|
||||
<>
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Encryption password:')}
|
||||
htmlFor="encrypt-password-field"
|
||||
/>
|
||||
<Input
|
||||
id="encrypt-password-field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChangeValue={value => {
|
||||
setPassword(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Confirm password:')}
|
||||
htmlFor="encrypt-confirm-field"
|
||||
/>
|
||||
<Input
|
||||
id="encrypt-confirm-field"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChangeValue={value => {
|
||||
setConfirmPassword(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isValid && <Error>{error}</Error>}
|
||||
</View>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ function useAddBudgetAccountOptions() {
|
||||
return { addOnBudgetAccountOption, addOffBudgetAccountOption };
|
||||
}
|
||||
|
||||
export type SelectLinkedAccountsModalProps =
|
||||
export type SelectLinkedAccountsModalProps = (
|
||||
| {
|
||||
requisitionId: string;
|
||||
externalAccounts: SyncServerGoCardlessAccount[];
|
||||
@@ -76,12 +76,16 @@ export type SelectLinkedAccountsModalProps =
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerPluggyAiAccount[];
|
||||
syncSource: 'pluggyai';
|
||||
};
|
||||
}
|
||||
) & {
|
||||
upgradingAccountId?: string;
|
||||
};
|
||||
|
||||
export function SelectLinkedAccountsModal({
|
||||
requisitionId = undefined,
|
||||
externalAccounts,
|
||||
syncSource,
|
||||
upgradingAccountId,
|
||||
}: SelectLinkedAccountsModalProps) {
|
||||
const propsWithSortedExternalAccounts =
|
||||
useMemo<SelectLinkedAccountsModalProps>(() => {
|
||||
@@ -122,11 +126,22 @@ export function SelectLinkedAccountsModal({
|
||||
>(new Map());
|
||||
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
|
||||
() => {
|
||||
return Object.fromEntries(
|
||||
const fromLinked = Object.fromEntries(
|
||||
localAccounts
|
||||
.filter(acc => acc.account_id)
|
||||
.map(acc => [acc.account_id, acc.id]),
|
||||
);
|
||||
if (
|
||||
upgradingAccountId &&
|
||||
externalAccounts?.length > 0 &&
|
||||
!Object.values(fromLinked).includes(upgradingAccountId)
|
||||
) {
|
||||
return {
|
||||
...fromLinked,
|
||||
[externalAccounts[0].account_id]: upgradingAccountId,
|
||||
};
|
||||
}
|
||||
return fromLinked;
|
||||
},
|
||||
);
|
||||
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { Toggle } from '@actual-app/components/toggle';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
@@ -21,6 +22,19 @@ import {
|
||||
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
function generateSalt(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
const cryptoObj =
|
||||
typeof globalThis !== 'undefined' &&
|
||||
(globalThis as { crypto?: Crypto }).crypto;
|
||||
if (cryptoObj?.getRandomValues) {
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < 32; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
type SimpleFinInitialiseModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'simplefin-init' }
|
||||
@@ -28,9 +42,13 @@ type SimpleFinInitialiseModalProps = Extract<
|
||||
|
||||
export const SimpleFinInitialiseModal = ({
|
||||
onSuccess,
|
||||
fileId,
|
||||
}: SimpleFinInitialiseModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState('');
|
||||
const [encryptSecrets, setEncryptSecrets] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(t('It is required to provide a token.'));
|
||||
@@ -40,20 +58,52 @@ export const SimpleFinInitialiseModal = ({
|
||||
setIsValid(false);
|
||||
return;
|
||||
}
|
||||
if (encryptSecrets) {
|
||||
if (!password || password.length < 1) {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
t('Encryption password is required when encryption is enabled.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setIsValid(false);
|
||||
setError(t('Password and confirmation do not match.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { error, reason } =
|
||||
(await send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: token,
|
||||
})) || {};
|
||||
|
||||
if (error) {
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(error, reason));
|
||||
if (encryptSecrets && password) {
|
||||
const salt = generateSalt();
|
||||
const { error: err, reason } =
|
||||
(await send('secret-set-encrypted', {
|
||||
name: 'simplefin_token',
|
||||
value: token,
|
||||
password,
|
||||
salt,
|
||||
fileId,
|
||||
})) || {};
|
||||
if (err) {
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
} else {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
onSuccess();
|
||||
const { error: err, reason } =
|
||||
(await send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: token,
|
||||
fileId,
|
||||
})) || {};
|
||||
if (err) {
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(err, reason));
|
||||
} else {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
close();
|
||||
@@ -64,7 +114,7 @@ export const SimpleFinInitialiseModal = ({
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Set-up SimpleFIN')}
|
||||
title={t('Set up SimpleFIN')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
@@ -97,6 +147,61 @@ export const SimpleFinInitialiseModal = ({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Toggle
|
||||
id="encrypt-simplefin"
|
||||
isOn={encryptSecrets}
|
||||
onToggle={setEncryptSecrets}
|
||||
/>
|
||||
<FormLabel
|
||||
title={t('Encrypt secrets')}
|
||||
htmlFor="encrypt-simplefin"
|
||||
/>
|
||||
</View>
|
||||
</FormField>
|
||||
|
||||
{encryptSecrets && (
|
||||
<>
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Encryption password:')}
|
||||
htmlFor="encrypt-password-field"
|
||||
/>
|
||||
<Input
|
||||
id="encrypt-password-field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChangeValue={value => {
|
||||
setPassword(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Confirm password:')}
|
||||
htmlFor="encrypt-confirm-field"
|
||||
/>
|
||||
<Input
|
||||
id="encrypt-confirm-field"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChangeValue={value => {
|
||||
setConfirmPassword(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isValid && <Error>{error}</Error>}
|
||||
</View>
|
||||
|
||||
|
||||
@@ -9,9 +9,13 @@ function _authorize(
|
||||
{
|
||||
onSuccess,
|
||||
onClose,
|
||||
fileId,
|
||||
password,
|
||||
}: {
|
||||
onSuccess: (data: GoCardlessToken) => Promise<void>;
|
||||
onClose?: () => void;
|
||||
fileId?: string;
|
||||
password?: string;
|
||||
},
|
||||
) {
|
||||
dispatch(
|
||||
@@ -19,10 +23,13 @@ function _authorize(
|
||||
modal: {
|
||||
name: 'gocardless-external-msg',
|
||||
options: {
|
||||
fileId,
|
||||
onMoveExternal: async ({ institutionId }) => {
|
||||
const resp = await send('gocardless-create-web-token', {
|
||||
institutionId,
|
||||
accessValidForDays: 90,
|
||||
...(fileId ? { fileId } : {}),
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
|
||||
if ('error' in resp) return resp;
|
||||
@@ -31,6 +38,8 @@ function _authorize(
|
||||
|
||||
return send('gocardless-poll-web-token', {
|
||||
requisitionId,
|
||||
...(fileId ? { fileId } : {}),
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
},
|
||||
onClose,
|
||||
@@ -41,8 +50,14 @@ function _authorize(
|
||||
);
|
||||
}
|
||||
|
||||
export async function authorizeBank(dispatch: AppDispatch) {
|
||||
export async function authorizeBank(
|
||||
dispatch: AppDispatch,
|
||||
fileId?: string,
|
||||
password?: string,
|
||||
) {
|
||||
_authorize(dispatch, {
|
||||
fileId,
|
||||
password,
|
||||
onSuccess: async data => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
import { useSyncServerStatus } from './useSyncServerStatus';
|
||||
|
||||
export function useGoCardlessStatus() {
|
||||
export function useGoCardlessStatus(fileId?: string) {
|
||||
const [configuredGoCardless, setConfiguredGoCardless] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
@@ -12,10 +12,15 @@ export function useGoCardlessStatus() {
|
||||
const status = useSyncServerStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) {
|
||||
setConfiguredGoCardless(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await send('gocardless-status');
|
||||
const results = await send('gocardless-status', { fileId });
|
||||
|
||||
setConfiguredGoCardless(results.configured || false);
|
||||
setIsLoading(false);
|
||||
@@ -24,7 +29,7 @@ export function useGoCardlessStatus() {
|
||||
if (status === 'online') {
|
||||
fetch();
|
||||
}
|
||||
}, [status]);
|
||||
}, [status, fileId]);
|
||||
|
||||
return {
|
||||
configuredGoCardless,
|
||||
|
||||
@@ -4,30 +4,45 @@ import { send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
import { useSyncServerStatus } from './useSyncServerStatus';
|
||||
|
||||
export function usePluggyAiStatus() {
|
||||
export function usePluggyAiStatus(fileId?: string) {
|
||||
const [configuredPluggyAi, setConfiguredPluggyAi] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [configuredPluggyAiScoped, setConfiguredPluggyAiScoped] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const status = useSyncServerStatus();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
if (!fileId) {
|
||||
setConfiguredPluggyAi(false);
|
||||
setConfiguredPluggyAiScoped(null);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await send('pluggyai-status');
|
||||
const result = await send('pluggyai-status', { fileId });
|
||||
|
||||
setConfiguredPluggyAi(results.configured || false);
|
||||
setConfiguredPluggyAi(
|
||||
(result as { configured?: boolean })?.configured || false,
|
||||
);
|
||||
setConfiguredPluggyAiScoped(
|
||||
(result as { configured?: boolean })?.configured || false,
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (status === 'online') {
|
||||
fetch();
|
||||
fetchStatus();
|
||||
}
|
||||
}, [status]);
|
||||
}, [status, fileId]);
|
||||
|
||||
return {
|
||||
configuredPluggyAi,
|
||||
configuredPluggyAiScoped: fileId ? configuredPluggyAiScoped : undefined,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
import { useSyncServerStatus } from './useSyncServerStatus';
|
||||
|
||||
export function useSimpleFinStatus() {
|
||||
export function useSimpleFinStatus(fileId?: string) {
|
||||
const [configuredSimpleFin, setConfiguredSimpleFin] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
@@ -12,10 +12,15 @@ export function useSimpleFinStatus() {
|
||||
const status = useSyncServerStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) {
|
||||
setConfiguredSimpleFin(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await send('simplefin-status');
|
||||
const results = await send('simplefin-status', { fileId });
|
||||
|
||||
setConfiguredSimpleFin(results.configured || false);
|
||||
setIsLoading(false);
|
||||
@@ -24,7 +29,7 @@ export function useSimpleFinStatus() {
|
||||
if (status === 'online') {
|
||||
fetch();
|
||||
}
|
||||
}, [status]);
|
||||
}, [status, fileId]);
|
||||
|
||||
return {
|
||||
configuredSimpleFin,
|
||||
|
||||
@@ -95,18 +95,28 @@ export type Modal =
|
||||
name: 'gocardless-init';
|
||||
options: {
|
||||
onSuccess: () => void;
|
||||
fileId: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'simplefin-init';
|
||||
options: {
|
||||
onSuccess: () => void;
|
||||
fileId: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'pluggyai-init';
|
||||
options: {
|
||||
onSuccess: () => void;
|
||||
fileId: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'bank-sync-password';
|
||||
options: {
|
||||
providers: Array<{ slug: string; displayName: string }>;
|
||||
onSubmit: (passwords: Record<string, string>) => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -121,6 +131,7 @@ export type Modal =
|
||||
>;
|
||||
onClose?: (() => void) | undefined;
|
||||
onSuccess: (data: GoCardlessToken) => Promise<void>;
|
||||
fileId?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -558,6 +569,13 @@ export type Modal =
|
||||
onUnlink: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'confirm-reset-credentials';
|
||||
options: {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'keyboard-shortcuts';
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../../types/models';
|
||||
import { createApp } from '../app';
|
||||
import * as db from '../db';
|
||||
import * as encryption from '../encryption';
|
||||
import {
|
||||
APIError,
|
||||
BankSyncError,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { app as mainApp } from '../main-app';
|
||||
import { mutator } from '../mutators';
|
||||
import { get, post } from '../post';
|
||||
import * as prefs from '../prefs';
|
||||
import { getServer } from '../server-config';
|
||||
import { batchMessages } from '../sync';
|
||||
import { undoable, withUndo } from '../undo';
|
||||
@@ -51,12 +53,14 @@ export type AccountHandlers = {
|
||||
'account-reopen': typeof reopenAccount;
|
||||
'account-move': typeof moveAccount;
|
||||
'secret-set': typeof setSecret;
|
||||
'secret-set-encrypted': typeof setSecretEncrypted;
|
||||
'secret-check': typeof checkSecret;
|
||||
'gocardless-poll-web-token': typeof pollGoCardlessWebToken;
|
||||
'gocardless-poll-web-token-stop': typeof stopGoCardlessWebTokenPolling;
|
||||
'gocardless-status': typeof goCardlessStatus;
|
||||
'simplefin-status': typeof simpleFinStatus;
|
||||
'pluggyai-status': typeof pluggyAiStatus;
|
||||
'check-provider-encryption': typeof checkProviderEncryption;
|
||||
'simplefin-accounts': typeof simpleFinAccounts;
|
||||
'pluggyai-accounts': typeof pluggyAiAccounts;
|
||||
'gocardless-get-banks': typeof getGoCardlessBanks;
|
||||
@@ -488,9 +492,11 @@ async function moveAccount({
|
||||
async function setSecret({
|
||||
name,
|
||||
value,
|
||||
fileId,
|
||||
}: {
|
||||
name: string;
|
||||
value: string | null;
|
||||
fileId?: string;
|
||||
}) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
@@ -509,6 +515,7 @@ async function setSecret({
|
||||
{
|
||||
name,
|
||||
value,
|
||||
...(fileId ? { fileId } : {}),
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
@@ -521,7 +528,52 @@ async function setSecret({
|
||||
};
|
||||
}
|
||||
}
|
||||
async function checkSecret(name: string) {
|
||||
|
||||
/**
|
||||
* Store a secret encrypted on the sync server. Uses the same logic as budget encryption:
|
||||
* client sends plaintext + password; server encrypts and stores (single source of truth).
|
||||
* Salt is ignored; server generates it (kept in signature for backward compat with callers).
|
||||
*/
|
||||
async function setSecretEncrypted({
|
||||
name,
|
||||
value,
|
||||
password,
|
||||
fileId,
|
||||
}: {
|
||||
name: string;
|
||||
value: string;
|
||||
password: string;
|
||||
salt?: string;
|
||||
fileId?: string;
|
||||
}) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
try {
|
||||
return await post(
|
||||
serverConfig.BASE_SERVER + '/secret',
|
||||
{
|
||||
name,
|
||||
value,
|
||||
password,
|
||||
...(fileId ? { fileId } : {}),
|
||||
},
|
||||
{ 'X-ACTUAL-TOKEN': userToken },
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: 'failed',
|
||||
reason: error instanceof PostError ? error.reason : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSecret(arg: string | { name: string; fileId?: string }) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
@@ -533,9 +585,18 @@ async function checkSecret(name: string) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const { name, fileId } =
|
||||
typeof arg === 'string' ? { name: arg, fileId: undefined } : arg;
|
||||
|
||||
try {
|
||||
return await get(serverConfig.BASE_SERVER + '/secret/' + name, {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
const url = new URL(serverConfig.BASE_SERVER + '/secret/' + name);
|
||||
if (fileId) {
|
||||
url.searchParams.set('fileId', fileId);
|
||||
}
|
||||
return await get(url.toString(), {
|
||||
headers: {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
@@ -547,11 +608,16 @@ let stopPolling = false;
|
||||
|
||||
async function pollGoCardlessWebToken({
|
||||
requisitionId,
|
||||
fileId,
|
||||
password,
|
||||
}: {
|
||||
requisitionId: string;
|
||||
fileId?: string;
|
||||
password?: string;
|
||||
}) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) return { error: 'unknown' };
|
||||
const token: string = userToken;
|
||||
|
||||
const startTime = Date.now();
|
||||
stopPolling = false;
|
||||
@@ -578,14 +644,20 @@ async function pollGoCardlessWebToken({
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const body: Record<string, string> = { requisitionId };
|
||||
if (fileId) body.fileId = fileId;
|
||||
if (password) body.password = password;
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': token,
|
||||
};
|
||||
if (fileId) {
|
||||
headers['X-Actual-File-Id'] = fileId;
|
||||
}
|
||||
|
||||
const data = await post(
|
||||
serverConfig.GOCARDLESS_SERVER + '/get-accounts',
|
||||
{
|
||||
requisitionId,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
body,
|
||||
headers,
|
||||
);
|
||||
|
||||
if (data) {
|
||||
@@ -625,7 +697,7 @@ async function stopGoCardlessWebTokenPolling() {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function goCardlessStatus() {
|
||||
async function goCardlessStatus(arg?: { fileId?: string }) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
@@ -637,136 +709,101 @@ async function goCardlessStatus() {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
return post(
|
||||
serverConfig.GOCARDLESS_SERVER + '/status',
|
||||
{},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
const body = arg?.fileId ? { fileId: arg.fileId } : {};
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (arg?.fileId) {
|
||||
headers['X-Actual-File-Id'] = arg.fileId;
|
||||
}
|
||||
|
||||
return post(serverConfig.GOCARDLESS_SERVER + '/status', body, headers);
|
||||
}
|
||||
|
||||
async function simpleFinStatus(arg?: { fileId?: string }) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const body = arg?.fileId ? { fileId: arg.fileId } : {};
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (arg?.fileId) {
|
||||
headers['X-Actual-File-Id'] = arg.fileId;
|
||||
}
|
||||
|
||||
return post(serverConfig.SIMPLEFIN_SERVER + '/status', body, headers);
|
||||
}
|
||||
|
||||
async function pluggyAiStatus(arg?: { fileId?: string }) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const body = arg?.fileId ? { fileId: arg.fileId } : {};
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (arg?.fileId) {
|
||||
headers['X-Actual-File-Id'] = arg.fileId;
|
||||
}
|
||||
|
||||
return post(serverConfig.PLUGGYAI_SERVER + '/status', body, headers);
|
||||
}
|
||||
|
||||
async function checkProviderEncryption(arg?: { fileId?: string }) {
|
||||
const fileId = arg?.fileId ?? prefs.getPrefs()?.id;
|
||||
if (!fileId) {
|
||||
return { goCardless: false, simpleFin: false, pluggyai: false };
|
||||
}
|
||||
|
||||
const [goCardlessRes, simpleFinRes, pluggyaiRes] = await Promise.all([
|
||||
goCardlessStatus({ fileId }).catch(() => ({ data: { encrypted: false } })),
|
||||
simpleFinStatus({ fileId }).catch(() => ({ data: { encrypted: false } })),
|
||||
pluggyAiStatus({ fileId }).catch(() => ({ data: { encrypted: false } })),
|
||||
]);
|
||||
|
||||
const goCardlessEncrypted = Boolean(
|
||||
goCardlessRes &&
|
||||
typeof goCardlessRes === 'object' &&
|
||||
(goCardlessRes as { data?: { encrypted?: boolean } }).data?.encrypted,
|
||||
);
|
||||
}
|
||||
|
||||
async function simpleFinStatus() {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
return post(
|
||||
serverConfig.SIMPLEFIN_SERVER + '/status',
|
||||
{},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
const simpleFinEncrypted = Boolean(
|
||||
simpleFinRes &&
|
||||
typeof simpleFinRes === 'object' &&
|
||||
(simpleFinRes as { data?: { encrypted?: boolean } }).data?.encrypted,
|
||||
);
|
||||
}
|
||||
|
||||
async function pluggyAiStatus() {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
return post(
|
||||
serverConfig.PLUGGYAI_SERVER + '/status',
|
||||
{},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
const pluggyaiEncrypted = Boolean(
|
||||
pluggyaiRes &&
|
||||
typeof pluggyaiRes === 'object' &&
|
||||
(pluggyaiRes as { data?: { encrypted?: boolean } }).data?.encrypted,
|
||||
);
|
||||
|
||||
return {
|
||||
goCardless: goCardlessEncrypted,
|
||||
simpleFin: simpleFinEncrypted,
|
||||
pluggyai: pluggyaiEncrypted,
|
||||
};
|
||||
}
|
||||
|
||||
async function simpleFinAccounts() {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
try {
|
||||
return await post(
|
||||
serverConfig.SIMPLEFIN_SERVER + '/accounts',
|
||||
{},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
60000,
|
||||
);
|
||||
} catch {
|
||||
return { error_code: 'TIMED_OUT' };
|
||||
}
|
||||
}
|
||||
|
||||
async function pluggyAiAccounts() {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
try {
|
||||
return await post(
|
||||
serverConfig.PLUGGYAI_SERVER + '/accounts',
|
||||
{},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
60000,
|
||||
);
|
||||
} catch {
|
||||
return { error_code: 'TIMED_OUT' };
|
||||
}
|
||||
}
|
||||
|
||||
async function getGoCardlessBanks(country: string) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
return post(
|
||||
serverConfig.GOCARDLESS_SERVER + '/get-banks',
|
||||
{ country, showDemo: isNonProductionEnvironment() },
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function createGoCardlessWebToken({
|
||||
institutionId,
|
||||
accessValidForDays,
|
||||
}: {
|
||||
institutionId: string;
|
||||
accessValidForDays: number;
|
||||
async function simpleFinAccounts(arg?: {
|
||||
fileId?: string;
|
||||
password?: string;
|
||||
}) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
@@ -779,16 +816,143 @@ async function createGoCardlessWebToken({
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
if (arg?.fileId) body.fileId = arg.fileId;
|
||||
if (arg?.password) body.password = arg.password;
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (arg?.fileId) {
|
||||
headers['X-Actual-File-Id'] = arg.fileId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await post(
|
||||
serverConfig.SIMPLEFIN_SERVER + '/accounts',
|
||||
body,
|
||||
headers,
|
||||
60000,
|
||||
);
|
||||
} catch {
|
||||
return { error_code: 'TIMED_OUT' };
|
||||
}
|
||||
}
|
||||
|
||||
async function pluggyAiAccounts(arg?: {
|
||||
fileId?: string;
|
||||
password?: string;
|
||||
}) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
if (arg?.fileId) body.fileId = arg.fileId;
|
||||
if (arg?.password) body.password = arg.password;
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (arg?.fileId) {
|
||||
headers['X-Actual-File-Id'] = arg.fileId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await post(
|
||||
serverConfig.PLUGGYAI_SERVER + '/accounts',
|
||||
body,
|
||||
headers,
|
||||
60000,
|
||||
);
|
||||
} catch {
|
||||
return { error_code: 'TIMED_OUT' };
|
||||
}
|
||||
}
|
||||
|
||||
async function getGoCardlessBanks(
|
||||
arg: string | { country: string; fileId?: string },
|
||||
) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const country = typeof arg === 'string' ? arg : arg.country;
|
||||
const fileId = typeof arg === 'string' ? undefined : arg.fileId;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
country,
|
||||
showDemo: isNonProductionEnvironment(),
|
||||
};
|
||||
if (fileId) {
|
||||
body.fileId = fileId;
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (fileId) {
|
||||
headers['X-Actual-File-Id'] = fileId;
|
||||
}
|
||||
|
||||
return post(serverConfig.GOCARDLESS_SERVER + '/get-banks', body, headers);
|
||||
}
|
||||
|
||||
async function createGoCardlessWebToken({
|
||||
institutionId,
|
||||
accessValidForDays,
|
||||
fileId,
|
||||
password,
|
||||
}: {
|
||||
institutionId: string;
|
||||
accessValidForDays: number;
|
||||
fileId?: string;
|
||||
password?: string;
|
||||
}) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
institutionId,
|
||||
accessValidForDays,
|
||||
};
|
||||
if (fileId) {
|
||||
body.fileId = fileId;
|
||||
}
|
||||
if (password) {
|
||||
body.password = password;
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (fileId) {
|
||||
headers['X-Actual-File-Id'] = fileId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await post(
|
||||
serverConfig.GOCARDLESS_SERVER + '/create-web-token',
|
||||
{
|
||||
institutionId,
|
||||
accessValidForDays,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
body,
|
||||
headers,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
@@ -895,11 +1059,14 @@ export type SyncResponseWithErrors = SyncResponse & {
|
||||
|
||||
async function accountsBankSync({
|
||||
ids = [],
|
||||
passwords,
|
||||
}: {
|
||||
ids: Array<AccountEntity['id']>;
|
||||
passwords?: Record<string, string>;
|
||||
}): Promise<SyncResponseWithErrors> {
|
||||
const { 'user-id': userId, 'user-key': userKey } =
|
||||
await asyncStorage.multiGet(['user-id', 'user-key']);
|
||||
const fileId = prefs.getPrefs()?.id ?? undefined;
|
||||
|
||||
const accounts = await db.runQuery<
|
||||
db.DbAccount & { bankId: db.DbBank['bank_id'] }
|
||||
@@ -925,12 +1092,17 @@ async function accountsBankSync({
|
||||
if (acct.bankId && acct.account_id) {
|
||||
try {
|
||||
logger.group('Bank Sync operation for account:', acct.name);
|
||||
const password =
|
||||
(acct.account_sync_source && passwords?.[acct.account_sync_source]) ??
|
||||
undefined;
|
||||
const syncResponse = await bankSync.syncAccount(
|
||||
userId as string,
|
||||
userKey as string,
|
||||
acct.id,
|
||||
acct.account_id,
|
||||
acct.bankId,
|
||||
password,
|
||||
fileId,
|
||||
);
|
||||
|
||||
const syncResponseData = await handleSyncResponse(syncResponse, acct);
|
||||
@@ -963,11 +1135,15 @@ async function accountsBankSync({
|
||||
|
||||
async function simpleFinBatchSync({
|
||||
ids = [],
|
||||
passwords,
|
||||
}: {
|
||||
ids: Array<AccountEntity['id']>;
|
||||
passwords?: Record<string, string>;
|
||||
}): Promise<
|
||||
Array<{ accountId: AccountEntity['id']; res: SyncResponseWithErrors }>
|
||||
> {
|
||||
const fileId = prefs.getPrefs()?.id ?? undefined;
|
||||
|
||||
const accounts = await db.runQuery<
|
||||
db.DbAccount & { bankId: db.DbBank['bank_id'] }
|
||||
>(
|
||||
@@ -1008,6 +1184,8 @@ async function simpleFinBatchSync({
|
||||
id: a.id,
|
||||
account_id: a.account_id || null,
|
||||
})),
|
||||
passwords?.simpleFin,
|
||||
fileId,
|
||||
);
|
||||
for (const syncResponse of syncResponses) {
|
||||
const account = accounts.find(a => a.id === syncResponse.accountId);
|
||||
@@ -1132,7 +1310,13 @@ async function importTransactions({
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkAccount({ id }: { id: AccountEntity['id'] }) {
|
||||
async function unlinkAccount({
|
||||
id,
|
||||
fileId,
|
||||
}: {
|
||||
id: AccountEntity['id'];
|
||||
fileId?: string;
|
||||
}) {
|
||||
const accRow = await db.first<db.DbAccount>(
|
||||
'SELECT * FROM accounts WHERE id = ?',
|
||||
[id],
|
||||
@@ -1194,14 +1378,20 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) {
|
||||
const requisitionId = bank.bank_id;
|
||||
|
||||
try {
|
||||
const body: Record<string, string> = { requisitionId };
|
||||
if (fileId) {
|
||||
body.fileId = fileId;
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (fileId) {
|
||||
headers['X-Actual-File-Id'] = fileId;
|
||||
}
|
||||
await post(
|
||||
serverConfig.GOCARDLESS_SERVER + '/remove-account',
|
||||
{
|
||||
requisitionId,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
body,
|
||||
headers,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log({ error });
|
||||
@@ -1225,12 +1415,14 @@ app.method('account-close', mutator(closeAccount));
|
||||
app.method('account-reopen', mutator(undoable(reopenAccount)));
|
||||
app.method('account-move', mutator(undoable(moveAccount)));
|
||||
app.method('secret-set', setSecret);
|
||||
app.method('secret-set-encrypted', setSecretEncrypted);
|
||||
app.method('secret-check', checkSecret);
|
||||
app.method('gocardless-poll-web-token', pollGoCardlessWebToken);
|
||||
app.method('gocardless-poll-web-token-stop', stopGoCardlessWebTokenPolling);
|
||||
app.method('gocardless-status', goCardlessStatus);
|
||||
app.method('simplefin-status', simpleFinStatus);
|
||||
app.method('pluggyai-status', pluggyAiStatus);
|
||||
app.method('check-provider-encryption', checkProviderEncryption);
|
||||
app.method('simplefin-accounts', simpleFinAccounts);
|
||||
app.method('pluggyai-accounts', pluggyAiAccounts);
|
||||
app.method('gocardless-get-banks', getGoCardlessBanks);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { aqlQuery } from '../aql';
|
||||
import * as db from '../db';
|
||||
import { runMutator } from '../mutators';
|
||||
import { post } from '../post';
|
||||
import * as prefs from '../prefs';
|
||||
import { getServer } from '../server-config';
|
||||
import { batchMessages } from '../sync';
|
||||
import { batchUpdateTransactions } from '../transactions';
|
||||
@@ -135,25 +136,34 @@ async function downloadGoCardlessTransactions(
|
||||
bankId,
|
||||
since,
|
||||
includeBalance = true,
|
||||
password?: string,
|
||||
fileId?: string,
|
||||
) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) return;
|
||||
|
||||
logger.log('Pulling transactions from GoCardless');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
userId,
|
||||
key: userKey,
|
||||
requisitionId: bankId,
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
includeBalance,
|
||||
};
|
||||
if (fileId) body.fileId = fileId;
|
||||
if (password) body.password = password;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (fileId) headers['X-Actual-File-Id'] = fileId;
|
||||
|
||||
const res = await post(
|
||||
getServer().GOCARDLESS_SERVER + '/transactions',
|
||||
{
|
||||
userId,
|
||||
key: userKey,
|
||||
requisitionId: bankId,
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
includeBalance,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
body,
|
||||
headers,
|
||||
);
|
||||
|
||||
if (res.error_code) {
|
||||
@@ -190,6 +200,8 @@ async function downloadGoCardlessTransactions(
|
||||
async function downloadSimpleFinTransactions(
|
||||
acctId: AccountEntity['id'] | AccountEntity['id'][],
|
||||
since: string | string[],
|
||||
password?: string,
|
||||
fileId?: string,
|
||||
) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) return;
|
||||
@@ -198,17 +210,24 @@ async function downloadSimpleFinTransactions(
|
||||
|
||||
logger.log('Pulling transactions from SimpleFin');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
};
|
||||
if (fileId) body.fileId = fileId;
|
||||
if (password) body.password = password;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (fileId) headers['X-Actual-File-Id'] = fileId;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await post(
|
||||
getServer().SIMPLEFIN_SERVER + '/transactions',
|
||||
{
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
body,
|
||||
headers,
|
||||
// 5 minute timeout for batch sync, one minute for individual accounts
|
||||
Array.isArray(acctId) ? 300000 : 60000,
|
||||
);
|
||||
@@ -260,21 +279,30 @@ async function downloadSimpleFinTransactions(
|
||||
async function downloadPluggyAiTransactions(
|
||||
acctId: AccountEntity['id'],
|
||||
since: string,
|
||||
password?: string,
|
||||
fileId?: string,
|
||||
) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) return;
|
||||
|
||||
logger.log('Pulling transactions from Pluggy.ai');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
};
|
||||
if (fileId) body.fileId = fileId;
|
||||
if (password) body.password = password;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
};
|
||||
if (fileId) headers['X-Actual-File-Id'] = fileId;
|
||||
|
||||
const res = await post(
|
||||
getServer().PLUGGYAI_SERVER + '/transactions',
|
||||
{
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
body,
|
||||
headers,
|
||||
60000,
|
||||
);
|
||||
|
||||
@@ -983,6 +1011,8 @@ export async function syncAccount(
|
||||
id: string,
|
||||
acctId: string,
|
||||
bankId: string,
|
||||
password?: string,
|
||||
fileId?: string,
|
||||
) {
|
||||
const acctRow = await db.select('accounts', id);
|
||||
|
||||
@@ -990,11 +1020,23 @@ export async function syncAccount(
|
||||
const oldestTransaction = await getAccountOldestTransaction(id);
|
||||
const newAccount = oldestTransaction == null;
|
||||
|
||||
const effectiveFileId = fileId ?? prefs.getPrefs()?.id;
|
||||
|
||||
let download;
|
||||
if (acctRow.account_sync_source === 'simpleFin') {
|
||||
download = await downloadSimpleFinTransactions(acctId, syncStartDate);
|
||||
download = await downloadSimpleFinTransactions(
|
||||
acctId,
|
||||
syncStartDate,
|
||||
password,
|
||||
effectiveFileId,
|
||||
);
|
||||
} else if (acctRow.account_sync_source === 'pluggyai') {
|
||||
download = await downloadPluggyAiTransactions(acctId, syncStartDate);
|
||||
download = await downloadPluggyAiTransactions(
|
||||
acctId,
|
||||
syncStartDate,
|
||||
password,
|
||||
effectiveFileId,
|
||||
);
|
||||
} else if (acctRow.account_sync_source === 'goCardless') {
|
||||
download = await downloadGoCardlessTransactions(
|
||||
userId,
|
||||
@@ -1003,6 +1045,8 @@ export async function syncAccount(
|
||||
bankId,
|
||||
syncStartDate,
|
||||
newAccount,
|
||||
password,
|
||||
effectiveFileId,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -1015,14 +1059,20 @@ export async function syncAccount(
|
||||
|
||||
export async function simpleFinBatchSync(
|
||||
accounts: Array<Pick<AccountEntity, 'id' | 'account_id'>>,
|
||||
password?: string,
|
||||
fileId?: string,
|
||||
) {
|
||||
const startDates = await Promise.all(
|
||||
accounts.map(async a => getAccountSyncStartDate(a.id)),
|
||||
);
|
||||
|
||||
const effectiveFileId = fileId ?? prefs.getPrefs()?.id;
|
||||
|
||||
const res = await downloadSimpleFinTransactions(
|
||||
accounts.map(a => a.account_id),
|
||||
startDates,
|
||||
password,
|
||||
effectiveFileId,
|
||||
);
|
||||
|
||||
const promises = [];
|
||||
|
||||
@@ -259,11 +259,13 @@ handlers['api/sync'] = async function () {
|
||||
|
||||
handlers['api/bank-sync'] = async function (args) {
|
||||
const batchSync = args?.accountId == null;
|
||||
const passwords = args?.passwords;
|
||||
const allErrors = [];
|
||||
|
||||
if (!batchSync) {
|
||||
const { errors } = await handlers['accounts-bank-sync']({
|
||||
ids: [args.accountId],
|
||||
passwords,
|
||||
});
|
||||
|
||||
allErrors.push(...errors);
|
||||
@@ -278,6 +280,7 @@ handlers['api/bank-sync'] = async function (args) {
|
||||
if (simpleFinAccounts.length > 1) {
|
||||
const res = await handlers['simplefin-batch-sync']({
|
||||
ids: simpleFinAccountIds,
|
||||
passwords,
|
||||
});
|
||||
|
||||
res.forEach(a => allErrors.push(...a.res.errors));
|
||||
@@ -285,6 +288,7 @@ handlers['api/bank-sync'] = async function (args) {
|
||||
|
||||
const { errors } = await handlers['accounts-bank-sync']({
|
||||
ids: accountIdsToSync.filter(a => !simpleFinAccountIds.includes(a)),
|
||||
passwords,
|
||||
});
|
||||
|
||||
allErrors.push(...errors);
|
||||
|
||||
@@ -130,7 +130,16 @@ export function getSyncError(error, id) {
|
||||
}
|
||||
|
||||
export function getBankSyncError(error: { message?: string }) {
|
||||
return error.message || t('We had an unknown problem syncing the account.');
|
||||
const msg = error.message;
|
||||
if (msg === 'decrypt-failure') {
|
||||
return t('Incorrect encryption password for bank sync. Please try again.');
|
||||
}
|
||||
if (msg === 'encrypted-secret-requires-password') {
|
||||
return t(
|
||||
'Bank sync secrets are encrypted. Please provide the encryption password when syncing.',
|
||||
);
|
||||
}
|
||||
return msg || t('We had an unknown problem syncing the account.');
|
||||
}
|
||||
|
||||
export class LazyLoadFailedError extends Error {
|
||||
|
||||
@@ -123,7 +123,8 @@ export type ApiHandlers = {
|
||||
'api/sync': () => Promise<void>;
|
||||
|
||||
'api/bank-sync': (arg?: {
|
||||
accountId: APIAccountEntity['id'];
|
||||
accountId?: APIAccountEntity['id'];
|
||||
passwords?: Record<string, string>;
|
||||
}) => Promise<void>;
|
||||
|
||||
'api/accounts-get': () => Promise<APIAccountEntity[]>;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getAccountDb } from '../src/account-db';
|
||||
|
||||
export const up = async function () {
|
||||
await getAccountDb().exec(`
|
||||
-- Create new secrets table with file_id column
|
||||
CREATE TABLE IF NOT EXISTS secrets_new (
|
||||
file_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value BLOB,
|
||||
PRIMARY KEY(file_id, name)
|
||||
);
|
||||
|
||||
-- Migrate existing secrets with a placeholder file_id (will be resolved by the next migration)
|
||||
INSERT INTO secrets_new (file_id, name, value)
|
||||
SELECT '__global__', name, value FROM secrets;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE secrets;
|
||||
ALTER TABLE secrets_new RENAME TO secrets;
|
||||
`);
|
||||
};
|
||||
|
||||
export const down = async function () {
|
||||
await getAccountDb().exec(`
|
||||
-- Create old secrets table structure
|
||||
CREATE TABLE IF NOT EXISTS secrets_old (
|
||||
name TEXT PRIMARY KEY,
|
||||
value BLOB
|
||||
);
|
||||
|
||||
-- Migrate only placeholder-scoped secrets back
|
||||
INSERT INTO secrets_old (name, value)
|
||||
SELECT name, value FROM secrets WHERE file_id = '__global__';
|
||||
|
||||
-- Drop new table and rename old one
|
||||
DROP TABLE secrets;
|
||||
ALTER TABLE secrets_old RENAME TO secrets;
|
||||
`);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getAccountDb } from '../src/account-db';
|
||||
|
||||
export const up = async function () {
|
||||
const db = getAccountDb();
|
||||
|
||||
const files = db.all('SELECT id FROM files WHERE deleted = 0');
|
||||
const globalSecrets = db.all(
|
||||
"SELECT name, value FROM secrets WHERE file_id = '__global__' OR file_id IS NULL",
|
||||
);
|
||||
|
||||
for (const secret of globalSecrets) {
|
||||
for (const file of files) {
|
||||
db.mutate(
|
||||
'INSERT OR IGNORE INTO secrets (file_id, name, value) VALUES (?, ?, ?)',
|
||||
[file.id, secret.name, secret.value],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
db.mutate(
|
||||
"DELETE FROM secrets WHERE file_id = '__global__' OR file_id IS NULL",
|
||||
);
|
||||
};
|
||||
|
||||
export const down = async function () {
|
||||
const db = getAccountDb();
|
||||
|
||||
const fileSecrets = db.all(
|
||||
"SELECT DISTINCT name, value FROM secrets WHERE file_id != '__global__'",
|
||||
);
|
||||
|
||||
for (const secret of fileSecrets) {
|
||||
db.mutate(
|
||||
"INSERT OR IGNORE INTO secrets (file_id, name, value) VALUES ('__global__', ?, ?)",
|
||||
[secret.name, secret.value],
|
||||
);
|
||||
}
|
||||
|
||||
db.exec("DELETE FROM secrets WHERE file_id != '__global__'");
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import { isAxiosError } from 'axios';
|
||||
import express from 'express';
|
||||
|
||||
import { SecretName, secretsService } from '../services/secrets-service';
|
||||
import { sha256String } from '../util/hash';
|
||||
import {
|
||||
requestLoggerMiddleware,
|
||||
@@ -18,6 +19,22 @@ import {
|
||||
import { goCardlessService } from './services/gocardless-service';
|
||||
import { handleError } from './util/handle-error';
|
||||
|
||||
function getFileIdFromRequest(req) {
|
||||
const fileId =
|
||||
req.body?.fileId || req.query?.fileId || req.headers['x-actual-file-id'];
|
||||
return fileId && typeof fileId === 'string' ? fileId : undefined;
|
||||
}
|
||||
|
||||
function getOptionsFromRequest(req) {
|
||||
const fileId = getFileIdFromRequest(req);
|
||||
const options = fileId ? { fileId } : {};
|
||||
const password = req.body?.password;
|
||||
if (password != null && password !== '') {
|
||||
options.password = password;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(requestLoggerMiddleware);
|
||||
|
||||
@@ -30,10 +47,19 @@ app.use(express.json());
|
||||
app.use(validateSessionMiddleware);
|
||||
|
||||
app.post('/status', async (req, res) => {
|
||||
const fileId = getFileIdFromRequest(req);
|
||||
const options = fileId ? { fileId } : {};
|
||||
const configured =
|
||||
secretsService.exists(SecretName.gocardless_secretId, options) &&
|
||||
secretsService.exists(SecretName.gocardless_secretKey, options);
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured: goCardlessService.isConfigured(),
|
||||
configured,
|
||||
encrypted: secretsService.isEncrypted(
|
||||
SecretName.gocardless_secretId,
|
||||
options,
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -43,11 +69,15 @@ app.post(
|
||||
handleError(async (req, res) => {
|
||||
const { institutionId } = req.body || {};
|
||||
const { origin } = req.headers;
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
const { link, requisitionId } = await goCardlessService.createRequisition({
|
||||
institutionId,
|
||||
host: origin,
|
||||
});
|
||||
const { link, requisitionId } = await goCardlessService.createRequisition(
|
||||
{
|
||||
institutionId,
|
||||
host: origin,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
@@ -63,10 +93,14 @@ app.post(
|
||||
'/get-accounts',
|
||||
handleError(async (req, res) => {
|
||||
const { requisitionId } = req.body || {};
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
try {
|
||||
const { requisition, accounts } =
|
||||
await goCardlessService.getRequisitionWithAccounts(requisitionId);
|
||||
await goCardlessService.getRequisitionWithAccounts(
|
||||
requisitionId,
|
||||
options,
|
||||
);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
@@ -98,9 +132,10 @@ app.post(
|
||||
'/get-banks',
|
||||
handleError(async (req, res) => {
|
||||
const { country, showDemo = false } = req.body || {};
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
await goCardlessService.setToken();
|
||||
const data = await goCardlessService.getInstitutions(country);
|
||||
await goCardlessService.setToken(options);
|
||||
const data = await goCardlessService.getInstitutions(country, options);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
@@ -121,8 +156,12 @@ app.post(
|
||||
'/remove-account',
|
||||
handleError(async (req, res) => {
|
||||
const { requisitionId } = req.body || {};
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
const data = await goCardlessService.deleteRequisition(requisitionId);
|
||||
const data = await goCardlessService.deleteRequisition(
|
||||
requisitionId,
|
||||
options,
|
||||
);
|
||||
if (data.summary === 'Requisition deleted') {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
@@ -150,6 +189,7 @@ app.post(
|
||||
accountId,
|
||||
includeBalance = true,
|
||||
} = req.body || {};
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
try {
|
||||
if (includeBalance) {
|
||||
@@ -163,6 +203,7 @@ app.post(
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
options,
|
||||
);
|
||||
|
||||
res.send({
|
||||
@@ -187,6 +228,7 @@ app.post(
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
options,
|
||||
);
|
||||
|
||||
res.send({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import jwt from 'jws';
|
||||
import * as nordigenNode from 'nordigen-node';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -22,19 +24,22 @@ const GoCardlessClient = nordigenNode.default;
|
||||
|
||||
const clients = new Map();
|
||||
|
||||
const getGocardlessClient = () => {
|
||||
const getGocardlessClient = (options = {}) => {
|
||||
const secrets = {
|
||||
secretId: secretsService.get(SecretName.gocardless_secretId),
|
||||
secretKey: secretsService.get(SecretName.gocardless_secretKey),
|
||||
secretId: secretsService.get(SecretName.gocardless_secretId, options),
|
||||
secretKey: secretsService.get(SecretName.gocardless_secretKey, options),
|
||||
};
|
||||
|
||||
const hash = JSON.stringify(secrets);
|
||||
|
||||
if (!clients.has(hash)) {
|
||||
clients.set(hash, new GoCardlessClient(secrets));
|
||||
let cacheKey = options.fileId ?? '';
|
||||
if (options.password != null && options.password !== '') {
|
||||
cacheKey += `:${crypto.createHash('sha256').update(options.password).digest('hex')}`;
|
||||
}
|
||||
|
||||
return clients.get(hash);
|
||||
if (!clients.has(cacheKey)) {
|
||||
clients.set(cacheKey, new GoCardlessClient(secrets));
|
||||
}
|
||||
|
||||
return clients.get(cacheKey);
|
||||
};
|
||||
|
||||
export const handleGoCardlessError = error => {
|
||||
@@ -65,19 +70,26 @@ export const handleGoCardlessError = error => {
|
||||
export const goCardlessService = {
|
||||
/**
|
||||
* Check if the GoCardless service is configured to be used.
|
||||
* @param {{ fileId?: string }} [options]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isConfigured: () => {
|
||||
return !!(
|
||||
getGocardlessClient().secretId && getGocardlessClient().secretKey
|
||||
isConfigured: (options = {}) => {
|
||||
const secretId = secretsService.get(
|
||||
SecretName.gocardless_secretId,
|
||||
options,
|
||||
);
|
||||
const secretKey = secretsService.get(
|
||||
SecretName.gocardless_secretKey,
|
||||
options,
|
||||
);
|
||||
return !!(secretId && secretKey);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
setToken: async () => {
|
||||
setToken: async (options = {}) => {
|
||||
const isExpiredJwtToken = token => {
|
||||
const decodedToken = jwt.decode(token);
|
||||
if (!decodedToken) {
|
||||
@@ -88,11 +100,11 @@ export const goCardlessService = {
|
||||
return clockTimestamp >= payload.exp;
|
||||
};
|
||||
|
||||
if (isExpiredJwtToken(getGocardlessClient().token)) {
|
||||
if (isExpiredJwtToken(getGocardlessClient(options).token)) {
|
||||
// Generate new access token. Token is valid for 24 hours
|
||||
// Note: access_token is automatically injected to other requests after you successfully obtain it
|
||||
try {
|
||||
await client.generateToken();
|
||||
await client.generateToken(options);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -113,8 +125,11 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<import('../gocardless-node.types').Requisition>}
|
||||
*/
|
||||
getLinkedRequisition: async requisitionId => {
|
||||
const requisition = await goCardlessService.getRequisition(requisitionId);
|
||||
getLinkedRequisition: async (requisitionId, options = {}) => {
|
||||
const requisition = await goCardlessService.getRequisition(
|
||||
requisitionId,
|
||||
options,
|
||||
);
|
||||
|
||||
const { status } = requisition;
|
||||
|
||||
@@ -142,14 +157,19 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<{requisition: import('../gocardless-node.types').Requisition, accounts: Array<import('../gocardless.types').NormalizedAccountDetails>}>}
|
||||
*/
|
||||
getRequisitionWithAccounts: async requisitionId => {
|
||||
const requisition =
|
||||
await goCardlessService.getLinkedRequisition(requisitionId);
|
||||
getRequisitionWithAccounts: async (requisitionId, options = {}) => {
|
||||
const requisition = await goCardlessService.getLinkedRequisition(
|
||||
requisitionId,
|
||||
options,
|
||||
);
|
||||
|
||||
const institutionIdSet = new Set();
|
||||
const detailedAccounts = await Promise.all(
|
||||
requisition.accounts.map(async accountId => {
|
||||
const account = await goCardlessService.getDetailedAccount(accountId);
|
||||
const account = await goCardlessService.getDetailedAccount(
|
||||
accountId,
|
||||
options,
|
||||
);
|
||||
institutionIdSet.add(account.institution_id);
|
||||
return account;
|
||||
}),
|
||||
@@ -157,7 +177,7 @@ export const goCardlessService = {
|
||||
|
||||
const institutions = await Promise.all(
|
||||
Array.from(institutionIdSet).map(async institutionId => {
|
||||
return await goCardlessService.getInstitution(institutionId);
|
||||
return await goCardlessService.getInstitution(institutionId, options);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -198,9 +218,10 @@ export const goCardlessService = {
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
options = {},
|
||||
) => {
|
||||
const { institution_id, accounts: accountIds } =
|
||||
await goCardlessService.getLinkedRequisition(requisitionId);
|
||||
await goCardlessService.getLinkedRequisition(requisitionId, options);
|
||||
|
||||
if (!accountIds.includes(accountId)) {
|
||||
throw new AccountNotLinkedToRequisition(accountId, requisitionId);
|
||||
@@ -212,8 +233,9 @@ export const goCardlessService = {
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
options,
|
||||
),
|
||||
goCardlessService.getBalances(accountId),
|
||||
goCardlessService.getBalances(accountId, options),
|
||||
]);
|
||||
|
||||
const transactions = normalizedTransactions.transactions;
|
||||
@@ -256,20 +278,24 @@ export const goCardlessService = {
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
options = {},
|
||||
) => {
|
||||
const { institution_id, accounts: accountIds } =
|
||||
await goCardlessService.getLinkedRequisition(requisitionId);
|
||||
await goCardlessService.getLinkedRequisition(requisitionId, options);
|
||||
|
||||
if (!accountIds.includes(accountId)) {
|
||||
throw new AccountNotLinkedToRequisition(accountId, requisitionId);
|
||||
}
|
||||
|
||||
const transactions = await goCardlessService.getTransactions({
|
||||
institutionId: institution_id,
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
const transactions = await goCardlessService.getTransactions(
|
||||
{
|
||||
institutionId: institution_id,
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const bank = BankFactory(institution_id);
|
||||
const sortedBookedTransactions = bank.sortTransactions(
|
||||
@@ -309,10 +335,13 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<{requisitionId, link}>}
|
||||
*/
|
||||
createRequisition: async ({ institutionId, host }) => {
|
||||
await goCardlessService.setToken();
|
||||
createRequisition: async ({ institutionId, host }, options = {}) => {
|
||||
await goCardlessService.setToken(options);
|
||||
|
||||
const institution = await goCardlessService.getInstitution(institutionId);
|
||||
const institution = await goCardlessService.getInstitution(
|
||||
institutionId,
|
||||
options,
|
||||
);
|
||||
const accountSelection =
|
||||
institution.supported_features?.includes('account_selection') ?? false;
|
||||
|
||||
@@ -331,7 +360,7 @@ export const goCardlessService = {
|
||||
accountSelection,
|
||||
};
|
||||
try {
|
||||
response = await client.initSession(body);
|
||||
response = await client.initSession(body, options);
|
||||
} catch {
|
||||
try {
|
||||
console.log('Failed to link using:');
|
||||
@@ -341,11 +370,14 @@ export const goCardlessService = {
|
||||
'and maxHistoricalDays = 89',
|
||||
);
|
||||
|
||||
response = await client.initSession({
|
||||
...body,
|
||||
accessValidForDays: 90,
|
||||
maxHistoricalDays: 89,
|
||||
});
|
||||
response = await client.initSession(
|
||||
{
|
||||
...body,
|
||||
accessValidForDays: 90,
|
||||
maxHistoricalDays: 89,
|
||||
},
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -372,12 +404,12 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<{summary: string, detail: string}>}
|
||||
*/
|
||||
deleteRequisition: async requisitionId => {
|
||||
await goCardlessService.getRequisition(requisitionId);
|
||||
deleteRequisition: async (requisitionId, options = {}) => {
|
||||
await goCardlessService.getRequisition(requisitionId, options);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = client.deleteRequisition(requisitionId);
|
||||
response = client.deleteRequisition(requisitionId, options);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -399,12 +431,12 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns { Promise<import('../gocardless-node.types').Requisition> }
|
||||
*/
|
||||
getRequisition: async requisitionId => {
|
||||
await goCardlessService.setToken();
|
||||
getRequisition: async (requisitionId, options = {}) => {
|
||||
await goCardlessService.setToken(options);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = client.getRequisitionById(requisitionId);
|
||||
response = client.getRequisitionById(requisitionId, options);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -417,12 +449,12 @@ export const goCardlessService = {
|
||||
* @param accountId
|
||||
* @returns {Promise<import('../gocardless.types').DetailedAccount>}
|
||||
*/
|
||||
getDetailedAccount: async accountId => {
|
||||
getDetailedAccount: async (accountId, options = {}) => {
|
||||
let detailedAccount, metadataAccount;
|
||||
try {
|
||||
[detailedAccount, metadataAccount] = await Promise.all([
|
||||
client.getDetails(accountId),
|
||||
client.getMetadata(accountId),
|
||||
client.getDetails(accountId, options),
|
||||
client.getMetadata(accountId, options),
|
||||
]);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
@@ -456,10 +488,10 @@ export const goCardlessService = {
|
||||
* @param accountId
|
||||
* @returns {Promise<import('../gocardless-node.types').GoCardlessAccountMetadata>}
|
||||
*/
|
||||
getAccountMetadata: async accountId => {
|
||||
getAccountMetadata: async (accountId, options = {}) => {
|
||||
let response;
|
||||
try {
|
||||
response = await client.getMetadata(accountId);
|
||||
response = await client.getMetadata(accountId, options);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -480,10 +512,10 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<Array<import('../gocardless-node.types').Institution>>}
|
||||
*/
|
||||
getInstitutions: async country => {
|
||||
getInstitutions: async (country, options = {}) => {
|
||||
let response;
|
||||
try {
|
||||
response = await client.getInstitutions(country);
|
||||
response = await client.getInstitutions(country, options);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -504,10 +536,10 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<import('../gocardless-node.types').Institution>}
|
||||
*/
|
||||
getInstitution: async institutionId => {
|
||||
getInstitution: async (institutionId, options = {}) => {
|
||||
let response;
|
||||
try {
|
||||
response = await client.getInstitutionById(institutionId);
|
||||
response = await client.getInstitutionById(institutionId, options);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -548,14 +580,20 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<import('../gocardless.types').GetTransactionsResponse>}
|
||||
*/
|
||||
getTransactions: async ({ institutionId, accountId, startDate, endDate }) => {
|
||||
getTransactions: async (
|
||||
{ institutionId, accountId, startDate, endDate },
|
||||
options = {},
|
||||
) => {
|
||||
let response;
|
||||
try {
|
||||
response = await client.getTransactions({
|
||||
accountId,
|
||||
dateFrom: startDate,
|
||||
dateTo: endDate,
|
||||
});
|
||||
response = await client.getTransactions(
|
||||
{
|
||||
accountId,
|
||||
dateFrom: startDate,
|
||||
dateTo: endDate,
|
||||
},
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -584,10 +622,10 @@ export const goCardlessService = {
|
||||
* @throws {ServiceError}
|
||||
* @returns {Promise<import('../gocardless.types').GetBalances>}
|
||||
*/
|
||||
getBalances: async accountId => {
|
||||
getBalances: async (accountId, options = {}) => {
|
||||
let response;
|
||||
try {
|
||||
response = await client.getBalances(accountId);
|
||||
response = await client.getBalances(accountId, options);
|
||||
} catch (error) {
|
||||
handleGoCardlessError(error);
|
||||
}
|
||||
@@ -602,38 +640,47 @@ export const goCardlessService = {
|
||||
* In that way we can mock the `client` const instead of nordigen library
|
||||
*/
|
||||
export const client = {
|
||||
getBalances: async accountId =>
|
||||
await getGocardlessClient().account(accountId).getBalances(),
|
||||
getTransactions: async ({ accountId, dateFrom, dateTo }) =>
|
||||
await getGocardlessClient().account(accountId).getTransactions({
|
||||
getBalances: async (accountId, options = {}) =>
|
||||
await getGocardlessClient(options).account(accountId).getBalances(),
|
||||
getTransactions: async ({ accountId, dateFrom, dateTo }, options = {}) =>
|
||||
await getGocardlessClient(options).account(accountId).getTransactions({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
country: undefined,
|
||||
}),
|
||||
getInstitutions: async country =>
|
||||
await getGocardlessClient().institution.getInstitutions({ country }),
|
||||
getInstitutionById: async institutionId =>
|
||||
await getGocardlessClient().institution.getInstitutionById(institutionId),
|
||||
getDetails: async accountId =>
|
||||
await getGocardlessClient().account(accountId).getDetails(),
|
||||
getMetadata: async accountId =>
|
||||
await getGocardlessClient().account(accountId).getMetadata(),
|
||||
getRequisitionById: async requisitionId =>
|
||||
await getGocardlessClient().requisition.getRequisitionById(requisitionId),
|
||||
deleteRequisition: async requisitionId =>
|
||||
await getGocardlessClient().requisition.deleteRequisition(requisitionId),
|
||||
initSession: async ({
|
||||
redirectUrl,
|
||||
institutionId,
|
||||
referenceId,
|
||||
accessValidForDays,
|
||||
maxHistoricalDays,
|
||||
userLanguage,
|
||||
ssn,
|
||||
redirectImmediate,
|
||||
accountSelection,
|
||||
}) =>
|
||||
await getGocardlessClient().initSession({
|
||||
getInstitutions: async (country, options = {}) =>
|
||||
await getGocardlessClient(options).institution.getInstitutions({ country }),
|
||||
getInstitutionById: async (institutionId, options = {}) =>
|
||||
await getGocardlessClient(options).institution.getInstitutionById(
|
||||
institutionId,
|
||||
),
|
||||
getDetails: async (accountId, options = {}) =>
|
||||
await getGocardlessClient(options).account(accountId).getDetails(),
|
||||
getMetadata: async (accountId, options = {}) =>
|
||||
await getGocardlessClient(options).account(accountId).getMetadata(),
|
||||
getRequisitionById: async (requisitionId, options = {}) =>
|
||||
await getGocardlessClient(options).requisition.getRequisitionById(
|
||||
requisitionId,
|
||||
),
|
||||
deleteRequisition: async (requisitionId, options = {}) =>
|
||||
await getGocardlessClient(options).requisition.deleteRequisition(
|
||||
requisitionId,
|
||||
),
|
||||
initSession: async (
|
||||
{
|
||||
redirectUrl,
|
||||
institutionId,
|
||||
referenceId,
|
||||
accessValidForDays,
|
||||
maxHistoricalDays,
|
||||
userLanguage,
|
||||
ssn,
|
||||
redirectImmediate,
|
||||
accountSelection,
|
||||
},
|
||||
options = {},
|
||||
) =>
|
||||
await getGocardlessClient(options).initSession({
|
||||
redirectUrl,
|
||||
institutionId,
|
||||
referenceId,
|
||||
@@ -644,7 +691,8 @@ export const client = {
|
||||
redirectImmediate,
|
||||
accountSelection,
|
||||
}),
|
||||
generateToken: async () => await getGocardlessClient().generateToken(),
|
||||
exchangeToken: async ({ refreshToken }) =>
|
||||
await getGocardlessClient().exchangeToken({ refreshToken }),
|
||||
generateToken: async (options = {}) =>
|
||||
await getGocardlessClient(options).generateToken(),
|
||||
exchangeToken: async ({ refreshToken }, options = {}) =>
|
||||
await getGocardlessClient(options).exchangeToken({ refreshToken }),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,14 @@ export function handleError(func) {
|
||||
return (req, res) => {
|
||||
func(req, res).catch(err => {
|
||||
console.log('Error', req.originalUrl, err.message || String(err));
|
||||
const reason = err.reason ?? err.message ?? 'internal-error';
|
||||
if (
|
||||
reason === 'decrypt-failure' ||
|
||||
reason === 'encrypted-secret-requires-password'
|
||||
) {
|
||||
res.status(400).send({ status: 'error', reason });
|
||||
return;
|
||||
}
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
|
||||
@@ -6,6 +6,22 @@ import { requestLoggerMiddleware } from '../util/middlewares';
|
||||
|
||||
import { pluggyaiService } from './pluggyai-service';
|
||||
|
||||
function getFileIdFromRequest(req) {
|
||||
const fileId =
|
||||
req.body?.fileId || req.query?.fileId || req.headers['x-actual-file-id'];
|
||||
return fileId && typeof fileId === 'string' ? fileId : undefined;
|
||||
}
|
||||
|
||||
function getOptionsFromRequest(req) {
|
||||
const fileId = getFileIdFromRequest(req);
|
||||
const options = fileId ? { fileId } : {};
|
||||
const password = req.body?.password;
|
||||
if (password != null && password !== '') {
|
||||
options.password = password;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
export { app as handlers };
|
||||
app.use(express.json());
|
||||
@@ -14,13 +30,21 @@ app.use(requestLoggerMiddleware);
|
||||
app.post(
|
||||
'/status',
|
||||
handleError(async (req, res) => {
|
||||
const clientId = secretsService.get(SecretName.pluggyai_clientId);
|
||||
const configured = clientId != null;
|
||||
const fileId = getFileIdFromRequest(req);
|
||||
const options = fileId ? { fileId } : {};
|
||||
const configured =
|
||||
secretsService.exists(SecretName.pluggyai_clientId, options) &&
|
||||
secretsService.exists(SecretName.pluggyai_clientSecret, options) &&
|
||||
secretsService.exists(SecretName.pluggyai_itemIds, options);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
encrypted: secretsService.isEncrypted(
|
||||
SecretName.pluggyai_clientId,
|
||||
options,
|
||||
),
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -29,16 +53,25 @@ app.post(
|
||||
app.post(
|
||||
'/accounts',
|
||||
handleError(async (req, res) => {
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
try {
|
||||
const itemIds = secretsService
|
||||
.get(SecretName.pluggyai_itemIds)
|
||||
const itemIdsRaw = secretsService.get(
|
||||
SecretName.pluggyai_itemIds,
|
||||
options,
|
||||
);
|
||||
const itemIds = (itemIdsRaw || '')
|
||||
.split(',')
|
||||
.map(item => item.trim());
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let accounts = [];
|
||||
|
||||
for (const item of itemIds) {
|
||||
const partial = await pluggyaiService.getAccountsByItemId(item);
|
||||
const partial = await pluggyaiService.getAccountsByItemId(
|
||||
item,
|
||||
options,
|
||||
);
|
||||
accounts = accounts.concat(partial.results);
|
||||
}
|
||||
|
||||
@@ -63,14 +96,16 @@ app.post(
|
||||
'/transactions',
|
||||
handleError(async (req, res) => {
|
||||
const { accountId, startDate } = req.body || {};
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
try {
|
||||
const transactions = await pluggyaiService.getTransactions(
|
||||
accountId,
|
||||
startDate,
|
||||
options,
|
||||
);
|
||||
|
||||
const account = await pluggyaiService.getAccountById(accountId);
|
||||
const account = await pluggyaiService.getAccountById(accountId, options);
|
||||
|
||||
let startingBalance = parseInt(
|
||||
Math.round(account.balance * 100).toString(),
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
|
||||
import { SecretName, secretsService } from '../services/secrets-service';
|
||||
|
||||
let pluggyClient = null;
|
||||
const pluggyClientCache = new Map();
|
||||
|
||||
function getPluggyClient() {
|
||||
if (!pluggyClient) {
|
||||
const clientId = secretsService.get(SecretName.pluggyai_clientId);
|
||||
const clientSecret = secretsService.get(SecretName.pluggyai_clientSecret);
|
||||
function getPluggyClient(options = {}) {
|
||||
let cacheKey = options.fileId ?? '';
|
||||
if (options.password != null && options.password !== '') {
|
||||
cacheKey += `:${crypto.createHash('sha256').update(options.password).digest('hex')}`;
|
||||
}
|
||||
if (!pluggyClientCache.has(cacheKey)) {
|
||||
const clientId = secretsService.get(SecretName.pluggyai_clientId, options);
|
||||
const clientSecret = secretsService.get(
|
||||
SecretName.pluggyai_clientSecret,
|
||||
options,
|
||||
);
|
||||
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
pluggyClientCache.set(
|
||||
cacheKey,
|
||||
new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return pluggyClient;
|
||||
return pluggyClientCache.get(cacheKey);
|
||||
}
|
||||
|
||||
export const pluggyaiService = {
|
||||
isConfigured: () => {
|
||||
isConfigured: (options = {}) => {
|
||||
return !!(
|
||||
secretsService.get(SecretName.pluggyai_clientId) &&
|
||||
secretsService.get(SecretName.pluggyai_clientSecret) &&
|
||||
secretsService.get(SecretName.pluggyai_itemIds)
|
||||
secretsService.get(SecretName.pluggyai_clientId, options) &&
|
||||
secretsService.get(SecretName.pluggyai_clientSecret, options) &&
|
||||
secretsService.get(SecretName.pluggyai_itemIds, options)
|
||||
);
|
||||
},
|
||||
|
||||
getAccountsByItemId: async itemId => {
|
||||
getAccountsByItemId: async (itemId, options = {}) => {
|
||||
try {
|
||||
const client = getPluggyClient();
|
||||
const client = getPluggyClient(options);
|
||||
const { results, total, ...rest } = await client.fetchAccounts(itemId);
|
||||
return {
|
||||
results,
|
||||
@@ -43,9 +55,9 @@ export const pluggyaiService = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getAccountById: async accountId => {
|
||||
getAccountById: async (accountId, options = {}) => {
|
||||
try {
|
||||
const client = getPluggyClient();
|
||||
const client = getPluggyClient(options);
|
||||
const account = await client.fetchAccount(accountId);
|
||||
return {
|
||||
...account,
|
||||
@@ -58,11 +70,17 @@ export const pluggyaiService = {
|
||||
}
|
||||
},
|
||||
|
||||
getTransactionsByAccountId: async (accountId, startDate, pageSize, page) => {
|
||||
getTransactionsByAccountId: async (
|
||||
accountId,
|
||||
startDate,
|
||||
pageSize,
|
||||
page,
|
||||
options = {},
|
||||
) => {
|
||||
try {
|
||||
const client = getPluggyClient();
|
||||
const client = getPluggyClient(options);
|
||||
|
||||
const account = await pluggyaiService.getAccountById(accountId);
|
||||
const account = await pluggyaiService.getAccountById(accountId, options);
|
||||
|
||||
// the sandbox data doesn't move the dates automatically so the
|
||||
// transactions are often older than 90 days. The owner on one of the
|
||||
@@ -95,13 +113,14 @@ export const pluggyaiService = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getTransactions: async (accountId, startDate) => {
|
||||
getTransactions: async (accountId, startDate, options = {}) => {
|
||||
let transactions = [];
|
||||
let result = await pluggyaiService.getTransactionsByAccountId(
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
1,
|
||||
options,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
@@ -111,6 +130,7 @@ export const pluggyaiService = {
|
||||
startDate,
|
||||
500,
|
||||
result.page + 1,
|
||||
options,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
|
||||
import { getAccountDb, isAdmin } from './account-db';
|
||||
import { encryptSecret } from './services/encryption-service';
|
||||
import { secretsService } from './services/secrets-service';
|
||||
import {
|
||||
requestLoggerMiddleware,
|
||||
@@ -29,7 +30,15 @@ app.post('/', async (req, res) => {
|
||||
details: 'Failed to validate authentication method',
|
||||
});
|
||||
}
|
||||
const { name, value } = req.body || {};
|
||||
const { name, value, fileId, password } = req.body || {};
|
||||
|
||||
if (!fileId) {
|
||||
return res.status(400).send({
|
||||
status: 'error',
|
||||
reason: 'missing-file-id',
|
||||
details: 'fileId is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'openid') {
|
||||
const canSaveSecrets = isAdmin(res.locals.user_id);
|
||||
@@ -45,14 +54,26 @@ app.post('/', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
secretsService.set(name, value);
|
||||
// Same logic as budget encryption: sync server encrypts/decrypts (single source of truth).
|
||||
// If password is provided, value is plaintext and we encrypt on the server.
|
||||
let valueToStore = value;
|
||||
if (password != null && password !== '' && value != null) {
|
||||
valueToStore = encryptSecret(String(value), password);
|
||||
}
|
||||
|
||||
secretsService.set(name, valueToStore, { fileId });
|
||||
|
||||
res.status(200).send({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.get('/:name', async (req, res) => {
|
||||
const name = req.params.name;
|
||||
const keyExists = secretsService.exists(name);
|
||||
// Support fileId via query param or header
|
||||
const fileId = req.query.fileId || req.headers['x-actual-file-id'];
|
||||
if (!fileId || typeof fileId !== 'string') {
|
||||
return res.status(400).send('fileId is required');
|
||||
}
|
||||
const keyExists = secretsService.exists(name, { fileId });
|
||||
if (keyExists) {
|
||||
res.sendStatus(204);
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,22 @@ import { handleError } from '../app-gocardless/util/handle-error';
|
||||
import { SecretName, secretsService } from '../services/secrets-service';
|
||||
import { requestLoggerMiddleware } from '../util/middlewares';
|
||||
|
||||
function getFileIdFromRequest(req) {
|
||||
const fileId =
|
||||
req.body?.fileId || req.query?.fileId || req.headers['x-actual-file-id'];
|
||||
return fileId && typeof fileId === 'string' ? fileId : undefined;
|
||||
}
|
||||
|
||||
function getOptionsFromRequest(req) {
|
||||
const fileId = getFileIdFromRequest(req);
|
||||
const options = fileId ? { fileId } : {};
|
||||
const password = req.body?.password;
|
||||
if (password != null && password !== '') {
|
||||
options.password = password;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
export { app as handlers };
|
||||
app.use(express.json());
|
||||
@@ -14,13 +30,21 @@ app.use(requestLoggerMiddleware);
|
||||
app.post(
|
||||
'/status',
|
||||
handleError(async (req, res) => {
|
||||
const token = secretsService.get(SecretName.simplefin_token);
|
||||
const configured = token != null && token !== 'Forbidden';
|
||||
const fileId = getFileIdFromRequest(req);
|
||||
const options = fileId ? { fileId } : {};
|
||||
const configured = secretsService.exists(
|
||||
SecretName.simplefin_token,
|
||||
options,
|
||||
);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
encrypted: secretsService.isEncrypted(
|
||||
SecretName.simplefin_token,
|
||||
options,
|
||||
),
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -29,16 +53,22 @@ app.post(
|
||||
app.post(
|
||||
'/accounts',
|
||||
handleError(async (req, res) => {
|
||||
let accessKey = secretsService.get(SecretName.simplefin_accessKey);
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
let accessKey = secretsService.get(SecretName.simplefin_accessKey, options);
|
||||
|
||||
try {
|
||||
if (accessKey == null || accessKey === 'Forbidden') {
|
||||
const token = secretsService.get(SecretName.simplefin_token);
|
||||
const token = secretsService.get(SecretName.simplefin_token, options);
|
||||
if (token == null || token === 'Forbidden') {
|
||||
throw new Error('No token');
|
||||
} else {
|
||||
accessKey = await getAccessKey(token);
|
||||
secretsService.set(SecretName.simplefin_accessKey, accessKey);
|
||||
secretsService.set(
|
||||
SecretName.simplefin_accessKey,
|
||||
accessKey,
|
||||
options,
|
||||
);
|
||||
if (accessKey == null || accessKey === 'Forbidden') {
|
||||
throw new Error('No access key');
|
||||
}
|
||||
@@ -69,8 +99,12 @@ app.post(
|
||||
'/transactions',
|
||||
handleError(async (req, res) => {
|
||||
const { accountId, startDate } = req.body || {};
|
||||
const options = getOptionsFromRequest(req);
|
||||
|
||||
const accessKey = secretsService.get(SecretName.simplefin_accessKey);
|
||||
const accessKey = secretsService.get(
|
||||
SecretName.simplefin_accessKey,
|
||||
options,
|
||||
);
|
||||
|
||||
if (accessKey == null || accessKey === 'Forbidden') {
|
||||
invalidToken(res);
|
||||
|
||||
@@ -5,41 +5,58 @@ import { secretsService } from './services/secrets-service';
|
||||
describe('secretsService', () => {
|
||||
const testSecretName = 'testSecret';
|
||||
const testSecretValue = 'testValue';
|
||||
const testOptions = { fileId: 'test-file-id' };
|
||||
|
||||
it('should set a secret', () => {
|
||||
const result = secretsService.set(testSecretName, testSecretValue);
|
||||
const result = secretsService.set(
|
||||
testSecretName,
|
||||
testSecretValue,
|
||||
testOptions,
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.changes).toBe(1);
|
||||
});
|
||||
|
||||
it('should get a secret', () => {
|
||||
const result = secretsService.get(testSecretName);
|
||||
const result = secretsService.get(testSecretName, testOptions);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toBe(testSecretValue);
|
||||
});
|
||||
|
||||
it('should check if a secret exists', () => {
|
||||
const exists = secretsService.exists(testSecretName);
|
||||
const exists = secretsService.exists(testSecretName, testOptions);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const nonExistent = secretsService.exists('nonExistentSecret');
|
||||
const nonExistent = secretsService.exists('nonExistentSecret', testOptions);
|
||||
expect(nonExistent).toBe(false);
|
||||
});
|
||||
|
||||
it('should update a secret', () => {
|
||||
const newValue = 'newValue';
|
||||
const setResult = secretsService.set(testSecretName, newValue);
|
||||
const setResult = secretsService.set(testSecretName, newValue, testOptions);
|
||||
expect(setResult).toBeDefined();
|
||||
expect(setResult.changes).toBe(1);
|
||||
|
||||
const getResult = secretsService.get(testSecretName);
|
||||
const getResult = secretsService.get(testSecretName, testOptions);
|
||||
expect(getResult).toBeDefined();
|
||||
expect(getResult).toBe(newValue);
|
||||
});
|
||||
|
||||
it('should throw when fileId is missing', () => {
|
||||
expect(() => secretsService.set(testSecretName, testSecretValue)).toThrow(
|
||||
'fileId is required',
|
||||
);
|
||||
expect(() => secretsService.get(testSecretName)).toThrow(
|
||||
'fileId is required',
|
||||
);
|
||||
expect(() => secretsService.exists(testSecretName)).toThrow(
|
||||
'fileId is required',
|
||||
);
|
||||
});
|
||||
|
||||
describe('secrets api', () => {
|
||||
it('returns 401 if the user is not authenticated', async () => {
|
||||
secretsService.set(testSecretName, testSecretValue);
|
||||
secretsService.set(testSecretName, testSecretValue, testOptions);
|
||||
const res = await request(app).get(`/${testSecretName}`);
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
@@ -59,20 +76,24 @@ describe('secretsService', () => {
|
||||
});
|
||||
|
||||
it('returns 204 if secret exists', async () => {
|
||||
secretsService.set(testSecretName, testSecretValue);
|
||||
secretsService.set(testSecretName, testSecretValue, testOptions);
|
||||
const res = await request(app)
|
||||
.get(`/${testSecretName}`)
|
||||
.get(`/${testSecretName}?fileId=test-file-id`)
|
||||
.set('x-actual-token', 'valid-token');
|
||||
|
||||
expect(res.statusCode).toEqual(204);
|
||||
});
|
||||
|
||||
it('returns 200 if secret was set', async () => {
|
||||
secretsService.set(testSecretName, testSecretValue);
|
||||
secretsService.set(testSecretName, testSecretValue, testOptions);
|
||||
const res = await request(app)
|
||||
.post(`/`)
|
||||
.set('x-actual-token', 'valid-token')
|
||||
.send({ name: testSecretName, value: testSecretValue });
|
||||
.send({
|
||||
name: testSecretName,
|
||||
value: testSecretValue,
|
||||
fileId: 'test-file-id',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
expect(res.body).toEqual({
|
||||
|
||||
111
packages/sync-server/src/services/encryption-service.js
Normal file
111
packages/sync-server/src/services/encryption-service.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
||||
const PBKDF2_ITERATIONS = 10000;
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Key derivation: same logic as loot-core server/encryption/encryption-internals.ts (Node).
|
||||
* createKeyBuffer(secret, salt) → pbkdf2Sync(secret, salt, 10000, 32, 'sha512').
|
||||
* Salt is passed as string; Node uses it as UTF-8.
|
||||
*/
|
||||
function createKeyBuffer(secret, salt) {
|
||||
return crypto.pbkdf2Sync(
|
||||
secret,
|
||||
salt,
|
||||
PBKDF2_ITERATIONS,
|
||||
KEY_LENGTH,
|
||||
'sha512',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a stored secret value is in the encrypted blob format.
|
||||
*
|
||||
* @param {string|object} value - The raw value from the database.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEncryptedValue(value) {
|
||||
if (value == null) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
parsed.encrypted === true &&
|
||||
typeof parsed.salt === 'string' &&
|
||||
typeof parsed.iv === 'string' &&
|
||||
typeof parsed.authTag === 'string' &&
|
||||
typeof parsed.value === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a secret stored in the encrypted blob format.
|
||||
* Uses the same logic as loot-core Node (encryption-internals.ts): key from createKeyBuffer(secret, salt).
|
||||
*
|
||||
* @param {string|object} encryptedBlob - JSON string or parsed object with encrypted, salt, iv, authTag, value.
|
||||
* @param {string} password - The decryption password.
|
||||
* @returns {string} The decrypted plaintext (UTF-8 string).
|
||||
* @throws {Error} On invalid blob format or decryption failure (e.g. wrong password).
|
||||
*/
|
||||
export function decryptSecret(encryptedBlob, password) {
|
||||
const parsed =
|
||||
typeof encryptedBlob === 'string'
|
||||
? JSON.parse(encryptedBlob)
|
||||
: encryptedBlob;
|
||||
|
||||
if (!isEncryptedValue(parsed)) {
|
||||
throw new Error('encrypted-secret-invalid-format');
|
||||
}
|
||||
|
||||
const key = createKeyBuffer(password, parsed.salt);
|
||||
const iv = Buffer.from(parsed.iv, 'base64');
|
||||
const authTag = Buffer.from(parsed.authTag, 'base64');
|
||||
const ciphertext = Buffer.from(parsed.value, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a plaintext with a password. Same logic as loot-core Node:
|
||||
* salt = random 32 bytes base64, key = createKeyBuffer(password, salt), AES-256-GCM.
|
||||
* Sync server is the single place that encrypts/decrypts bank secrets (source of truth).
|
||||
*
|
||||
* @param {string} plaintext - Value to encrypt (UTF-8).
|
||||
* @param {string} password - Encryption password.
|
||||
* @returns {string} JSON string of blob { encrypted, salt, iv, authTag, value }.
|
||||
*/
|
||||
export function encryptSecret(plaintext, password) {
|
||||
const salt = crypto.randomBytes(32).toString('base64');
|
||||
const key = createKeyBuffer(password, salt);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(Buffer.from(plaintext, 'utf8')),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const blob = {
|
||||
encrypted: true,
|
||||
salt,
|
||||
iv: iv.toString('base64'),
|
||||
authTag: authTag.toString('base64'),
|
||||
value: encrypted.toString('base64'),
|
||||
};
|
||||
return JSON.stringify(blob);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import createDebug from 'debug';
|
||||
|
||||
import { getAccountDb } from '../account-db';
|
||||
|
||||
import { decryptSecret, isEncryptedValue } from './encryption-service';
|
||||
|
||||
/**
|
||||
* An enum of valid secret names.
|
||||
* @readonly
|
||||
@@ -27,68 +29,169 @@ class SecretsDb {
|
||||
return getAccountDb();
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
/**
|
||||
* Resolve the file ID from options.
|
||||
* @param {Object} options
|
||||
* @param {string} options.fileId - The file ID for the secret.
|
||||
* @returns {string} The file ID.
|
||||
*/
|
||||
_resolveFileId(options = {}) {
|
||||
if (!options.fileId) {
|
||||
throw new Error('fileId is required for secret operations');
|
||||
}
|
||||
return options.fileId;
|
||||
}
|
||||
|
||||
set(name, value, options = {}) {
|
||||
if (!this.db) {
|
||||
this.db = this.open();
|
||||
}
|
||||
|
||||
this.debug(`setting secret '${name}' to '${value}'`);
|
||||
const fileId = this._resolveFileId(options);
|
||||
this.debug(`setting secret '${name}' for file '${fileId}' to '${value}'`);
|
||||
const result = this.db.mutate(
|
||||
`INSERT OR REPLACE INTO secrets (name, value) VALUES (?,?)`,
|
||||
[name, value],
|
||||
`INSERT OR REPLACE INTO secrets (file_id, name, value) VALUES (?,?,?)`,
|
||||
[fileId, name, value],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
get(name) {
|
||||
get(name, options = {}) {
|
||||
if (!this.db) {
|
||||
this.db = this.open();
|
||||
}
|
||||
|
||||
this.debug(`getting secret '${name}'`);
|
||||
const result = this.db.first(`SELECT value FROM secrets WHERE name =?`, [
|
||||
name,
|
||||
]);
|
||||
const fileId = this._resolveFileId(options);
|
||||
this.debug(`getting secret '${name}' for file '${fileId}'`);
|
||||
const result = this.db.first(
|
||||
`SELECT value FROM secrets WHERE file_id = ? AND name = ?`,
|
||||
[fileId, name],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const secretsDb = new SecretsDb();
|
||||
const _cachedSecrets = new Map();
|
||||
|
||||
/**
|
||||
* Create a cache key from file ID and name
|
||||
* @param {string} fileId
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function _createCacheKey(fileId, name) {
|
||||
return `${fileId}:${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A service for managing secrets stored in `secretsDb`.
|
||||
*/
|
||||
export const secretsService = {
|
||||
/**
|
||||
* Retrieves the value of a secret by name.
|
||||
* If the secret is stored encrypted, options.password must be provided to decrypt.
|
||||
* @param {SecretName} name - The name of the secret to retrieve.
|
||||
* @returns {string|null} The value of the secret, or null if the secret does not exist.
|
||||
* @param {Object} options - Scope configuration.
|
||||
* @param {string} options.fileId - The file ID for the secret.
|
||||
* @param {string} [options.password] - Password to decrypt encrypted secrets.
|
||||
* @returns {string|null} The value of the secret (decrypted if encrypted), or null if the secret does not exist.
|
||||
* @throws {Error} If the secret is encrypted and options.password is missing or wrong (reason: 'encrypted-secret-requires-password' or 'decrypt-failure').
|
||||
*/
|
||||
get: name => {
|
||||
return _cachedSecrets.get(name) ?? secretsDb.get(name)?.value ?? null;
|
||||
get: (name, options = {}) => {
|
||||
const fileId = secretsDb._resolveFileId(options);
|
||||
const cacheKey = _createCacheKey(fileId, name);
|
||||
const raw =
|
||||
_cachedSecrets.get(cacheKey) ??
|
||||
secretsDb.get(name, options)?.value ??
|
||||
null;
|
||||
|
||||
if (raw == null) return null;
|
||||
|
||||
const rawStr =
|
||||
typeof raw === 'string'
|
||||
? raw
|
||||
: Buffer.isBuffer(raw)
|
||||
? raw.toString('utf8')
|
||||
: String(raw);
|
||||
|
||||
if (isEncryptedValue(rawStr)) {
|
||||
const password = options.password;
|
||||
if (password == null || password === '') {
|
||||
const err = new Error('encrypted-secret-requires-password');
|
||||
err.reason = 'encrypted-secret-requires-password';
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return decryptSecret(rawStr, password);
|
||||
} catch (e) {
|
||||
const err = new Error(e.message || 'decrypt-failure');
|
||||
err.reason = 'decrypt-failure';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return rawStr;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines whether a secret is stored in encrypted format.
|
||||
* @param {SecretName} name - The name of the secret to check.
|
||||
* @param {Object} options - Scope configuration.
|
||||
* @param {string} options.fileId - The file ID for the secret.
|
||||
* @returns {boolean} True if the secret exists and is encrypted, false otherwise.
|
||||
*/
|
||||
isEncrypted: (name, options = {}) => {
|
||||
const fileId = secretsDb._resolveFileId(options);
|
||||
const cacheKey = _createCacheKey(fileId, name);
|
||||
const raw =
|
||||
_cachedSecrets.get(cacheKey) ??
|
||||
secretsDb.get(name, options)?.value ??
|
||||
null;
|
||||
if (raw == null) return false;
|
||||
const rawStr =
|
||||
typeof raw === 'string'
|
||||
? raw
|
||||
: Buffer.isBuffer(raw)
|
||||
? raw.toString('utf8')
|
||||
: raw;
|
||||
return isEncryptedValue(rawStr);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the value of a secret by name.
|
||||
* @param {SecretName} name - The name of the secret to set.
|
||||
* @param {string} value - The value to set for the secret.
|
||||
* @param {Object} options - Scope configuration.
|
||||
* @param {string} options.fileId - The file ID for the secret.
|
||||
* @returns {Object}
|
||||
*/
|
||||
set: (name, value) => {
|
||||
const result = secretsDb.set(name, value);
|
||||
set: (name, value, options = {}) => {
|
||||
const result = secretsDb.set(name, value, options);
|
||||
|
||||
if (result.changes === 1) {
|
||||
_cachedSecrets.set(name, value);
|
||||
const fileId = secretsDb._resolveFileId(options);
|
||||
const cacheKey = _createCacheKey(fileId, name);
|
||||
_cachedSecrets.set(cacheKey, value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines whether a secret with the given name exists.
|
||||
* Does not require a password for encrypted secrets.
|
||||
* @param {SecretName} name - The name of the secret to check for existence.
|
||||
* @param {Object} options - Scope configuration.
|
||||
* @param {string} options.fileId - The file ID for the secret.
|
||||
* @returns {boolean} True if a secret with the given name exists, false otherwise.
|
||||
*/
|
||||
exists: name => {
|
||||
return Boolean(secretsService.get(name));
|
||||
exists: (name, options = {}) => {
|
||||
const fileId = secretsDb._resolveFileId(options);
|
||||
const cacheKey = _createCacheKey(fileId, name);
|
||||
const raw =
|
||||
_cachedSecrets.get(cacheKey) ??
|
||||
secretsDb.get(name, options)?.value ??
|
||||
null;
|
||||
return raw != null;
|
||||
},
|
||||
};
|
||||
|
||||
6
upcoming-release-notes/6879.md
Normal file
6
upcoming-release-notes/6879.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Add per-file scoped bank sync with encrypted secrets, provider-centric UI, and password management modals.
|
||||
Reference in New Issue
Block a user