Compare commits

...

7 Commits

Author SHA1 Message Date
github-actions[bot]
3f6247144e Add release notes for PR #6879 2026-02-09 13:26:01 +00:00
lelemm
e0183e5d01 Delete upcoming-release-notes/6879.md 2026-02-09 09:56:36 -03:00
lelemm
fe9264151c Adding bank sync api keys encryption. Not functional 2026-02-06 19:33:10 -03:00
lelemm
faa8d7c222 Removed global scope, added gocardless to scope, refactored once more the bank sync page 2026-02-06 03:14:44 -03:00
github-actions[bot]
0e840ca136 Add release notes for PR #6879 2026-02-05 22:48:19 +00:00
lelemm
7c6284a791 Merge branch 'master' into feat/scoped-bank-sync 2026-02-05 19:40:41 -03:00
lelemm
e0231286ae Scoped Bank Sync 2026-02-05 19:38:50 -03:00
40 changed files with 2925 additions and 670 deletions

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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>

View File

@@ -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}
/>
);
})}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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>
);

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
)}
</>
)}

View File

@@ -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,
},
},
}),

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 } =

View File

@@ -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>

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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';
}

View File

@@ -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);

View File

@@ -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 = [];

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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[]>;

View File

@@ -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;
`);
};

View File

@@ -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__'");
};

View File

@@ -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({

View File

@@ -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 }),
};

View File

@@ -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: {

View File

@@ -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(),

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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({

View 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);
}

View File

@@ -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;
},
};

View 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.