feat: DB-first bank sync config, admin toggle, extracted BankSyncSection
New: services/bankSyncConfigService.js — bank_sync_enabled from settings table, env fallback client/components/admin/BankSyncAdminCard.jsx — single toggle + encryption key status client/components/data/BankSyncSection.jsx — full connection management extracted from SettingsPage Modified: routes/dataSources.js — per-request getBankSyncConfig() instead of module-level env check routes/admin.js — GET/PUT /api/admin/bank-sync-config AdminPage.jsx — renders BankSyncAdminCard after EmailNotifCard SettingsPage.jsx — BankSyncSection removed, 580->352 lines DataPage.jsx — BankSyncSection first, passes simplefinConn to TransactionMatchingSection TransactionMatchingSection.jsx — compact sync bar with green dot + Sync Now Layout.jsx — SimplefinBadge shows muted dot when enabled client/api.js — bankSyncConfig API calls
This commit is contained in:
parent
979886cb6a
commit
88a4b64924
|
|
@ -309,6 +309,10 @@ export const api = {
|
|||
syncDataSource: (id) => post(`/data-sources/${id}/sync`),
|
||||
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
||||
|
||||
// Admin — bank sync feature flag
|
||||
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
||||
setBankSyncConfig: (data) => put('/admin/bank-sync-config', data),
|
||||
|
||||
// User SQLite import
|
||||
previewUserDbImport: async (file) => {
|
||||
const res = await fetch('/api/import/user-db/preview', {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Toggle } from './adminShared';
|
||||
|
||||
export default function BankSyncAdminCard() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.bankSyncConfig()
|
||||
.then(d => {
|
||||
setConfig(d);
|
||||
setEnabled(!!d.enabled);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.setBankSyncConfig({ enabled });
|
||||
setConfig(result);
|
||||
setEnabled(!!result.enabled);
|
||||
toast.success(enabled ? 'Bank sync enabled.' : 'Bank sync disabled.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to update bank sync setting.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Loading…
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const keySet = config?.encryption_key_set;
|
||||
const changed = enabled !== !!config?.enabled;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle>Bank Sync (SimpleFIN)</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Allow users to connect their own SimpleFIN Bridge account to sync
|
||||
read-only bank transactions. Each user manages their own connection
|
||||
from the Data page — no bank credentials are stored.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
|
||||
{/* Encryption key status */}
|
||||
<div className={`rounded-lg border px-4 py-3 text-sm ${
|
||||
keySet
|
||||
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
: 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-400'
|
||||
}`}>
|
||||
{keySet
|
||||
? 'TOKEN_ENCRYPTION_KEY is configured. Bank sync can be enabled.'
|
||||
: 'TOKEN_ENCRYPTION_KEY is not set. Add a 32+ character key to your environment before enabling bank sync.'}
|
||||
</div>
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Allow users to connect SimpleFIN</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
When enabled, users see a Bank Sync section on their Data page.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={v => setEnabled(v)}
|
||||
disabled={!keySet}
|
||||
label="Enable bank sync"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={saving || !changed || (!enabled ? false : !keySet)}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Building2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { SectionCard } from './dataShared';
|
||||
|
||||
export default function BankSyncSection({ onConnectionChange }) {
|
||||
const [enabled, setEnabled] = useState(null);
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [setupToken, setSetupToken] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(null);
|
||||
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadError('');
|
||||
try {
|
||||
const [status, sources] = await Promise.all([
|
||||
api.simplefinStatus(),
|
||||
api.dataSources({ type: 'provider_sync' }),
|
||||
]);
|
||||
setEnabled(status.enabled);
|
||||
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
|
||||
setConnections(conns);
|
||||
onConnectionChange?.(conns[0] || null);
|
||||
} catch (err) {
|
||||
setEnabled(false);
|
||||
setLoadError(err.message || 'Failed to load bank sync status');
|
||||
}
|
||||
}, [onConnectionChange]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
const token = setupToken.trim();
|
||||
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
|
||||
setConnecting(true);
|
||||
try {
|
||||
const result = await api.connectSimplefin(token);
|
||||
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
|
||||
setSetupToken('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to connect SimpleFIN');
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (id) => {
|
||||
setSyncing(id);
|
||||
try {
|
||||
const result = await api.syncDataSource(id);
|
||||
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Sync failed');
|
||||
await load();
|
||||
} finally {
|
||||
setSyncing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!disconnectTarget) return;
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await api.deleteDataSource(disconnectTarget.id);
|
||||
toast.success('SimpleFIN disconnected.');
|
||||
setDisconnectTarget(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to disconnect');
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
if (enabled === null) {
|
||||
return (
|
||||
<SectionCard title="Bank Sync">
|
||||
<div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard title="Bank Sync" subtitle="Connect your SimpleFIN Bridge to sync read-only bank transactions.">
|
||||
{!enabled ? (
|
||||
<div className="px-6 py-5 text-sm text-muted-foreground">
|
||||
Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{loadError && (
|
||||
<div className="px-6 py-3 text-sm text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{connections.length > 0 && connections.map(conn => (
|
||||
<div key={conn.id} className="px-6 py-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{conn.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
|
||||
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
onClick={() => handleSync(conn.id)}
|
||||
disabled={syncing === conn.id}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
{syncing === conn.id
|
||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing…</>
|
||||
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
onClick={() => setDisconnectTarget(conn)}
|
||||
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Link2Off className="h-3.5 w-3.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 text-xs">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<p className="text-muted-foreground">Last sync</p>
|
||||
<p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'rounded-lg border px-3 py-2',
|
||||
conn.last_error ? 'border-destructive/30 bg-destructive/5' : 'border-border/60 bg-muted/20',
|
||||
)}>
|
||||
<p className="text-muted-foreground">Status</p>
|
||||
<p className={cn('font-medium mt-0.5', conn.last_error ? 'text-destructive' : '')}>
|
||||
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{connections.length === 0 && (
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
|
||||
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={setupToken}
|
||||
onChange={e => setSetupToken(e.target.value)}
|
||||
placeholder="Paste SimpleFIN setup token…"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
||||
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting…</> : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connections.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border/50 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={setupToken}
|
||||
onChange={e => setSetupToken(e.target.value)}
|
||||
placeholder="Paste SimpleFIN setup token…"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
||||
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting…</> : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disconnect SimpleFIN?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the connection and deletes synced accounts. Previously synced transactions
|
||||
are kept but will no longer be associated with a data source.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={disconnecting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{disconnecting ? 'Disconnecting…' : 'Disconnect'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -294,7 +294,16 @@ function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, lo
|
|||
);
|
||||
}
|
||||
|
||||
export default function TransactionMatchingSection({ refreshKey }) {
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return null;
|
||||
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
||||
return `${Math.floor(secs / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export default function TransactionMatchingSection({ refreshKey, simplefinConn }) {
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [bills, setBills] = useState([]);
|
||||
|
|
@ -423,11 +432,53 @@ export default function TransactionMatchingSection({ refreshKey }) {
|
|||
}
|
||||
};
|
||||
|
||||
const [quickSyncing, setQuickSyncing] = useState(false);
|
||||
|
||||
const handleQuickSync = async () => {
|
||||
if (!simplefinConn) return;
|
||||
setQuickSyncing(true);
|
||||
try {
|
||||
await api.syncDataSource(simplefinConn.id);
|
||||
await refreshTransactionWorkbench();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Sync failed');
|
||||
} finally {
|
||||
setQuickSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Transactions"
|
||||
subtitle="Review imported or manual transactions and confirm matches to bills."
|
||||
>
|
||||
{simplefinConn && (
|
||||
<div className="px-6 py-2 flex items-center justify-between gap-3 border-b border-border/50 bg-muted/20 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn(
|
||||
'h-1.5 w-1.5 rounded-full shrink-0',
|
||||
simplefinConn.last_error ? 'bg-destructive' : 'bg-emerald-500',
|
||||
)} />
|
||||
SimpleFIN
|
||||
{simplefinConn.last_sync_at && (
|
||||
<span>· synced {timeAgo(simplefinConn.last_sync_at)}</span>
|
||||
)}
|
||||
{simplefinConn.last_error && (
|
||||
<span className="text-destructive">· {simplefinConn.last_error}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickSync}
|
||||
disabled={quickSyncing}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{quickSyncing
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" />Syncing…</>
|
||||
: <><RefreshCw className="h-3 w-3" />Sync Now</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,26 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import AppNavigation from './Sidebar';
|
||||
import { api } from '@/api';
|
||||
|
||||
function SimplefinBadge() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.simplefinStatus()
|
||||
.then(d => setEnabled(!!d.enabled))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
|
||||
SimpleFIN
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ mainContentId }) {
|
||||
return (
|
||||
|
|
@ -21,6 +42,7 @@ export default function Layout({ mainContentId }) {
|
|||
>
|
||||
<Link to="/about" className="underline-offset-4 hover:text-foreground hover:underline">About</Link>
|
||||
<Link to="/release-notes" className="underline-offset-4 hover:text-foreground hover:underline">Release Notes</Link>
|
||||
<SimplefinBadge />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { api } from '@/api';
|
|||
import AppNavigation from '@/components/layout/Sidebar';
|
||||
import OnboardingWizard from '@/components/admin/OnboardingWizard';
|
||||
import EmailNotifCard from '@/components/admin/EmailNotifCard';
|
||||
import BankSyncAdminCard from '@/components/admin/BankSyncAdminCard';
|
||||
import LoginModeCard from '@/components/admin/LoginModeCard';
|
||||
import AuthMethodsCard from '@/components/admin/AuthMethodsCard';
|
||||
import UsersTable from '@/components/admin/UsersTable';
|
||||
|
|
@ -69,6 +70,7 @@ export default function AdminPage() {
|
|||
) : (
|
||||
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8 space-y-6">
|
||||
<EmailNotifCard />
|
||||
<BankSyncAdminCard />
|
||||
<BackupManagementCard />
|
||||
<CleanupPanel />
|
||||
<LoginModeCard users={users} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '@/api';
|
||||
import BankSyncSection from '@/components/data/BankSyncSection';
|
||||
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
||||
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
|
||||
import ImportSpreadsheetSection from '@/components/data/ImportSpreadsheetSection';
|
||||
|
|
@ -9,9 +10,10 @@ import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
|
|||
import ImportHistorySection from '@/components/data/ImportHistorySection';
|
||||
|
||||
export default function DataPage() {
|
||||
const [history, setHistory] = useState(null);
|
||||
const [history, setHistory] = useState(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(true);
|
||||
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
|
||||
const [simplefinConn, setSimplefinConn] = useState(null);
|
||||
|
||||
const loadHistory = async () => {
|
||||
setHistoryLoading(true);
|
||||
|
|
@ -32,6 +34,12 @@ export default function DataPage() {
|
|||
setTransactionRefreshKey(key => key + 1);
|
||||
};
|
||||
|
||||
// Called by BankSyncSection when connection state changes (connect/sync/disconnect)
|
||||
const handleConnectionChange = useCallback((conn) => {
|
||||
setSimplefinConn(conn || null);
|
||||
setTransactionRefreshKey(key => key + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
|
|
@ -47,8 +55,9 @@ export default function DataPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<BankSyncSection onConnectionChange={handleConnectionChange} />
|
||||
<ImportTransactionCsvSection onHistoryRefresh={handleTransactionImportComplete} />
|
||||
<TransactionMatchingSection refreshKey={transactionRefreshKey} />
|
||||
<TransactionMatchingSection refreshKey={transactionRefreshKey} simplefinConn={simplefinConn} />
|
||||
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
||||
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertCircle, Building2, Link2Off, Loader2, Moon, RefreshCw, Sun, Users } from 'lucide-react';
|
||||
import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
|
|
@ -212,239 +208,6 @@ function SettingsSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Bank Sync Section ────────────────────────────────────────────────────────
|
||||
|
||||
function BankSyncSection() {
|
||||
const [enabled, setEnabled] = useState(null); // null = loading
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [setupToken, setSetupToken] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(null); // id being synced
|
||||
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadError('');
|
||||
try {
|
||||
const [status, sources] = await Promise.all([
|
||||
api.simplefinStatus(),
|
||||
api.dataSources({ type: 'provider_sync' }),
|
||||
]);
|
||||
setEnabled(status.enabled);
|
||||
setConnections(Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []);
|
||||
} catch (err) {
|
||||
setEnabled(false);
|
||||
setLoadError(err.message || 'Failed to load bank sync status');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
const token = setupToken.trim();
|
||||
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
|
||||
setConnecting(true);
|
||||
try {
|
||||
const result = await api.connectSimplefin(token);
|
||||
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
|
||||
setSetupToken('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to connect SimpleFIN');
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (id) => {
|
||||
setSyncing(id);
|
||||
try {
|
||||
const result = await api.syncDataSource(id);
|
||||
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Sync failed');
|
||||
await load();
|
||||
} finally {
|
||||
setSyncing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!disconnectTarget) return;
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await api.deleteDataSource(disconnectTarget.id);
|
||||
toast.success('SimpleFIN disconnected.');
|
||||
setDisconnectTarget(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to disconnect');
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
if (enabled === null) {
|
||||
return (
|
||||
<SectionCard title="Bank Sync">
|
||||
<div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard title="Bank Sync">
|
||||
{!enabled ? (
|
||||
<div className="px-6 py-5 text-sm text-muted-foreground">
|
||||
Bank sync is not enabled on this server.
|
||||
Set <code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">BANK_SYNC_ENABLED=true</code> and
|
||||
a <code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">TOKEN_ENCRYPTION_KEY</code> in your
|
||||
environment to enable SimpleFIN.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{loadError && (
|
||||
<div className="px-6 py-3 text-sm text-destructive">{loadError}</div>
|
||||
)}
|
||||
|
||||
{/* Connected accounts */}
|
||||
{connections.length > 0 && connections.map(conn => (
|
||||
<div key={conn.id} className="px-6 py-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{conn.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
|
||||
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
onClick={() => handleSync(conn.id)}
|
||||
disabled={syncing === conn.id}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
{syncing === conn.id
|
||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing…</>
|
||||
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
onClick={() => setDisconnectTarget(conn)}
|
||||
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Link2Off className="h-3.5 w-3.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 text-xs">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<p className="text-muted-foreground">Last sync</p>
|
||||
<p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'rounded-lg border px-3 py-2',
|
||||
conn.last_error ? 'border-destructive/30 bg-destructive/5' : 'border-border/60 bg-muted/20',
|
||||
)}>
|
||||
<p className="text-muted-foreground">Status</p>
|
||||
<p className={cn('font-medium mt-0.5', conn.last_error ? 'text-destructive' : '')}>
|
||||
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Connect form */}
|
||||
{connections.length === 0 && (
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
|
||||
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={setupToken}
|
||||
onChange={e => setSetupToken(e.target.value)}
|
||||
placeholder="Paste SimpleFIN setup token…"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !setupToken.trim()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting…</> : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add another connection — only show if already connected */}
|
||||
{connections.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border/50 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={setupToken}
|
||||
onChange={e => setSetupToken(e.target.value)}
|
||||
placeholder="Paste SimpleFIN setup token…"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !setupToken.trim()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting…</> : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disconnect SimpleFIN?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the connection and deletes synced accounts. Previously synced transactions
|
||||
are kept but will no longer be associated with a data source.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={disconnecting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{disconnecting ? 'Disconnecting…' : 'Disconnect'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SettingsPage ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
|
|
@ -576,8 +339,6 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* Bank Sync */}
|
||||
<BankSyncSection />
|
||||
|
||||
{/* Save button — right-aligned below all cards */}
|
||||
<div className="flex justify-end mt-6">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb, rollbackMigration } = require('../db/database');
|
||||
const { getBankSyncConfig, setBankSyncEnabled } = require('../services/bankSyncConfigService');
|
||||
const { hashPassword } = require('../services/authService');
|
||||
const { logAudit } = require('../services/auditService');
|
||||
const {
|
||||
|
|
@ -396,6 +397,26 @@ router.put('/auth-mode', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ── Bank Sync Config ──────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/admin/bank-sync-config
|
||||
router.get('/bank-sync-config', (req, res) => {
|
||||
res.json(getBankSyncConfig());
|
||||
});
|
||||
|
||||
// PUT /api/admin/bank-sync-config
|
||||
router.put('/bank-sync-config', (req, res) => {
|
||||
const enabled = req.body?.enabled;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
}
|
||||
try {
|
||||
res.json(setBankSyncEnabled(enabled));
|
||||
} catch (err) {
|
||||
res.status(err.status || 500).json({ error: err.message || 'Failed to update bank sync config' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Migration Rollback ────────────────────────────────────────────────────────
|
||||
router.post('/migrations/rollback', async (req, res) => {
|
||||
const { version } = req.body;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ const { standardizeError } = require('../middleware/erro
|
|||
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService');
|
||||
const { connectSimplefin, syncDataSource, disconnectDataSource } = require('../services/bankSyncService');
|
||||
const { sanitizeErrorMessage } = require('../services/simplefinService');
|
||||
|
||||
const BANK_SYNC_ENABLED = process.env.BANK_SYNC_ENABLED === 'true';
|
||||
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
|
||||
|
||||
const VALID_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
|
||||
const VALID_STATUSES = new Set(['active', 'inactive', 'error']);
|
||||
|
|
@ -63,13 +62,14 @@ router.get('/', (req, res) => {
|
|||
// ─── GET /api/data-sources/simplefin/status ──────────────────────────────────
|
||||
|
||||
router.get('/simplefin/status', (req, res) => {
|
||||
res.json({ enabled: BANK_SYNC_ENABLED });
|
||||
const { enabled } = getBankSyncConfig();
|
||||
res.json({ enabled });
|
||||
});
|
||||
|
||||
// ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
|
||||
|
||||
router.post('/simplefin/connect', async (req, res) => {
|
||||
if (!BANK_SYNC_ENABLED) {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ router.post('/simplefin/connect', async (req, res) => {
|
|||
// ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
|
||||
|
||||
router.post('/:id/sync', async (req, res) => {
|
||||
if (!BANK_SYNC_ENABLED) {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ router.post('/:id/sync', async (req, res) => {
|
|||
// ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
if (!BANK_SYNC_ENABLED) {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
'use strict';
|
||||
|
||||
const { getSetting, setSetting } = require('../db/database');
|
||||
|
||||
const SYNC_DAYS_DEFAULT = 90;
|
||||
|
||||
function encryptionKeyReady() {
|
||||
const key = process.env.TOKEN_ENCRYPTION_KEY || '';
|
||||
return Buffer.from(key, 'utf8').length >= 32;
|
||||
}
|
||||
|
||||
function getBankSyncConfig() {
|
||||
const dbValue = getSetting('bank_sync_enabled');
|
||||
const envValue = process.env.BANK_SYNC_ENABLED;
|
||||
|
||||
let enabled;
|
||||
if (dbValue !== null && dbValue !== undefined && dbValue !== '') {
|
||||
enabled = dbValue === 'true';
|
||||
} else if (envValue !== undefined && envValue !== '') {
|
||||
enabled = envValue === 'true';
|
||||
} else {
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
const syncDaysDb = parseInt(getSetting('simplefin_sync_days') || '', 10);
|
||||
const syncDaysEnv = parseInt(process.env.SIMPLEFIN_SYNC_DAYS || '', 10);
|
||||
const syncDays = Number.isFinite(syncDaysDb) && syncDaysDb > 0
|
||||
? syncDaysDb
|
||||
: Number.isFinite(syncDaysEnv) && syncDaysEnv > 0
|
||||
? syncDaysEnv
|
||||
: SYNC_DAYS_DEFAULT;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
encryption_key_set: encryptionKeyReady(),
|
||||
sync_days: syncDays,
|
||||
};
|
||||
}
|
||||
|
||||
function setBankSyncEnabled(enabled) {
|
||||
if (enabled && !encryptionKeyReady()) {
|
||||
const err = new Error('TOKEN_ENCRYPTION_KEY must be set (32+ chars) before enabling bank sync');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
setSetting('bank_sync_enabled', enabled ? 'true' : 'false');
|
||||
return getBankSyncConfig();
|
||||
}
|
||||
|
||||
module.exports = { getBankSyncConfig, setBankSyncEnabled };
|
||||
Loading…
Reference in New Issue