Add CSV import UI, recurring transactions, and security fixes

- FileUpload: full rewrite with column mapping, split debit/credit columns,
  negate toggle, duplicate detection, OFX/QFX support, and live preview
- ManualEntry: add recurring transaction checkbox with weekly/biweekly/monthly/yearly options
- DataInput: pass existingTransactions to FileUpload for duplicate detection
- Settings: use session token for delete-account; replace reset-code password
  flow with proper current+new password form
- page.tsx: fix TypeScript build error by mapping date strings to Date objects
  after setup wizard completes
- SetupWizard: fix hasSplit boolean type error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 08:56:59 -04:00
1 parent 8f9e9269ec
commit 18fe6b5141
6 files changed
+346 -311

No files matched your search

+5 -1
View File
@@ -332,9 +332,13 @@ export default function BudgetDashboard() {
const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance);
return { ...a, startingBalance: base, balance: base };
});
const txnsWithDates = (userData.transactions ?? []).map((t: any) => ({
...t,
date: typeof t.date === 'string' ? new Date(t.date) : t.date,
}));
setAccounts(accsWithBase);
setEnvelopes(userData.envelopes ?? envs);
setTransactions(userData.transactions ?? []);
setTransactions(txnsWithDates);
setGoals(userData.goals ?? []);
setTransactionRules(userData.transactionRules ?? []);
} else {
+3 -1
View File
@@ -55,6 +55,8 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr
date: Date;
envelopeId?: string;
accountId: string;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
}) => {
const newTransaction = {
id: crypto.randomUUID(),
@@ -103,7 +105,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr
transactions={transactions}
/>
) : (
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} />
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} existingTransactions={transactions as any} />
)}
{/* Help Text */}
+259 -241
View File
@@ -5,6 +5,7 @@ import { useState } from 'react';
interface FileUploadProps {
onTransactionsParsed: (transactions: ParsedTransaction[]) => void;
accounts: { id: string; name: string }[];
existingTransactions?: { description: string; date: Date | string; amount: number }[];
}
export interface ParsedTransaction {
@@ -15,265 +16,282 @@ export interface ParsedTransaction {
accountId?: string;
}
export default function FileUpload({ onTransactionsParsed, accounts }: FileUploadProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<string>('csv');
const [isProcessing, setIsProcessing] = useState(false);
const [selectedAccountId, setSelectedAccountId] = useState<string>('');
interface CsvRow { [key: string]: string }
const supportedFormats = [
{ value: 'csv', label: 'CSV (Comma Separated Values)', extensions: '.csv' },
{ value: 'ofx', label: 'OFX (Open Financial Exchange)', extensions: '.ofx' },
{ value: 'qfx', label: 'QFX (Quicken Financial Exchange)', extensions: '.qfx' },
{ value: 'qbo', label: 'QBO (QuickBooks Online)', extensions: '.qbo' },
{ value: 'pdf', label: 'PDF Statement', extensions: '.pdf' },
];
export default function FileUpload({ onTransactionsParsed, accounts, existingTransactions = [] }: FileUploadProps) {
const [selectedAccountId, setSelectedAccountId] = useState(accounts?.[0]?.id || '');
const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
const [csvRows, setCsvRows] = useState<CsvRow[]>([]);
const [fileName, setFileName] = useState('');
const [dateCol, setDateCol] = useState('');
const [descCol, setDescCol] = useState('');
const [amtCol, setAmtCol] = useState('');
const [amtInCol, setAmtInCol] = useState('');
const [amtOutCol, setAmtOutCol] = useState('');
const [splitAmounts, setSplitAmounts] = useState(false);
const [negateAmt, setNegateAmt] = useState(false);
const [skipDuplicates, setSkipDuplicates] = useState(true);
const [fileType, setFileType] = useState<'csv' | 'ofx'>('csv');
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
const parseRow = (line: string): string[] => {
const result: string[] = [];
let cur = '';
let inQuote = false;
for (const ch of line) {
if (ch === '"') { inQuote = !inQuote; }
else if (ch === ',' && !inQuote) { result.push(cur.trim()); cur = ''; }
else { cur += ch; }
}
result.push(cur.trim());
return result;
};
const parseCSV = (content: string): ParsedTransaction[] => {
const lines = content.split('\n');
const transactions: ParsedTransaction[] = [];
const handleFile = (file: File) => {
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
const isOfx = ['ofx', 'qfx', 'qbo'].includes(ext);
setFileType(isOfx ? 'ofx' : 'csv');
setFileName(file.name);
// Try to detect header row and column positions
const firstLine = lines[0]?.trim();
if (!firstLine) return transactions;
const headers = firstLine.split(',').map(h => h.trim().toLowerCase());
const dateIndex = headers.findIndex(h => h.includes('date') || h.includes('dt'));
const amountIndex = headers.findIndex(h => h.includes('amount') || h.includes('amt') || h.includes('value'));
const descIndex = headers.findIndex(h => h.includes('description') || h.includes('desc') || h.includes('memo') || h.includes('reference'));
const categoryIndex = headers.findIndex(h => h.includes('category') || h.includes('cat'));
// Skip header row
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const columns = line.split(',');
if (columns.length >= 3) {
const dateStr = columns[dateIndex]?.trim();
const amountStr = columns[amountIndex]?.trim();
const descStr = columns[descIndex]?.trim();
const categoryStr = columns[categoryIndex]?.trim();
if (!dateStr || !amountStr || !descStr) continue;
// Try multiple date formats
let date: Date | null = null;
const dateFormats = [
// MM/DD/YYYY
() => {
const parts = dateStr.split('/');
if (parts.length === 3) {
return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
}
return null;
},
// YYYY-MM-DD
() => new Date(dateStr + 'T00:00:00'),
// MM-DD-YYYY
() => {
const parts = dateStr.split('-');
if (parts.length === 3) {
return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
}
return null;
},
// DD/MM/YYYY
() => {
const parts = dateStr.split('/');
if (parts.length === 3) {
return new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
}
return null;
}
];
for (const formatFn of dateFormats) {
date = formatFn();
if (date && !isNaN(date.getTime())) break;
}
const amount = parseFloat(amountStr.replace(/[$,]/g, ''));
if (date && !isNaN(date.getTime()) && !isNaN(amount)) {
transactions.push({
date,
amount,
description: descStr,
category: categoryStr || undefined,
accountId: selectedAccountId || undefined,
});
}
const reader = new FileReader();
reader.onload = e => {
const text = e.target?.result as string;
if (isOfx) {
const txns = parseOFX(text);
onTransactionsParsed(txns.map(t => ({ ...t, accountId: selectedAccountId || undefined })));
return;
}
}
return transactions;
};
const parseOFX = (content: string): ParsedTransaction[] => {
// Basic OFX parsing - this would need to be more robust for production
const transactions: ParsedTransaction[] = [];
const transactionRegex = /<STMTTRN>[\s\S]*?<\/STMTTRN>/g;
const matches = content.match(transactionRegex);
if (matches) {
matches.forEach(match => {
const dateMatch = match.match(/<DTPOSTED>(.*?)<\/DTPOSTED>/);
const amountMatch = match.match(/<TRNAMT>(.*?)<\/TRNAMT>/);
const descMatch = match.match(/<MEMO>(.*?)<\/MEMO>/) || match.match(/<NAME>(.*?)<\/NAME>/);
if (dateMatch && amountMatch && descMatch) {
const dateStr = dateMatch[1].substring(0, 8); // YYYYMMDD format
const date = new Date(
parseInt(dateStr.substring(0, 4)),
parseInt(dateStr.substring(4, 6)) - 1,
parseInt(dateStr.substring(6, 8))
);
const amount = parseFloat(amountMatch[1]);
const description = descMatch[1];
transactions.push({
date,
amount,
description,
});
}
// CSV flow
const lines = text.split(/\r?\n/).filter(l => l.trim());
if (lines.length < 2) return;
const headers = parseRow(lines[0]);
const rows: CsvRow[] = lines.slice(1).map(line => {
const vals = parseRow(line);
const row: CsvRow = {};
headers.forEach((h, i) => { row[h] = vals[i] ?? ''; });
return row;
});
}
setCsvHeaders(headers);
setCsvRows(rows);
return transactions;
// Auto-detect columns
const find = (...candidates: string[]) =>
headers.find(h => candidates.some(c => h.toLowerCase().includes(c))) ?? '';
const d = find('date', 'posted', 'transaction date');
const desc = find('description', 'desc', 'memo', 'payee', 'name');
const amt = find('amount', 'amt', 'transaction amount');
const inC = find('credit', 'deposit', 'money in');
const outC = find('debit', 'withdrawal', 'money out');
const hasSplit = !amt && !!(inC || outC);
setDateCol(d); setDescCol(desc); setAmtCol(amt);
setAmtInCol(inC); setAmtOutCol(outC); setSplitAmounts(hasSplit);
};
reader.readAsText(file);
};
const handleUpload = async () => {
if (!selectedFile) return;
setIsProcessing(true);
try {
const content = await selectedFile.text();
let transactions: ParsedTransaction[] = [];
switch (fileType) {
case 'csv':
transactions = parseCSV(content);
break;
case 'ofx':
case 'qfx':
transactions = parseOFX(content);
break;
case 'qbo':
// QBO parsing would be similar to OFX
transactions = parseOFX(content);
break;
case 'pdf':
// PDF parsing would require a PDF parsing library
alert('PDF parsing is not yet implemented. Please use CSV or OFX format.');
setIsProcessing(false);
return;
default:
alert('Unsupported file format');
setIsProcessing(false);
return;
}
onTransactionsParsed(transactions);
setSelectedFile(null);
// Reset file input
const fileInput = document.getElementById('file-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
} catch (error) {
console.error('Error parsing file:', error);
alert('Error parsing file. Please check the format and try again.');
} finally {
setIsProcessing(false);
}
const parseOFX = (text: string): ParsedTransaction[] => {
const txns: ParsedTransaction[] = [];
const matches = text.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/gi) ?? [];
matches.forEach(m => {
const dateM = m.match(/<DTPOSTED>(.*?)</i);
const amtM = m.match(/<TRNAMT>(.*?)</i);
const descM = m.match(/<MEMO>(.*?)</i) ?? m.match(/<NAME>(.*?)</i);
if (!dateM || !amtM || !descM) return;
const ds = dateM[1].substring(0, 8);
txns.push({
date: new Date(parseInt(ds.slice(0,4)), parseInt(ds.slice(4,6))-1, parseInt(ds.slice(6,8))),
amount: parseFloat(amtM[1]),
description: descM[1],
});
});
return txns;
};
const parseAmt = (s: string) => parseFloat((s ?? '').replace(/[$,\s]/g, '').replace(/\((.+)\)/, '-$1')) || 0;
const buildPreview = (): { date: string; desc: string; amt: number }[] =>
csvRows.slice(0, 5).map(row => {
const amt = splitAmounts
? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? ''))
: negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]);
return { date: row[dateCol] ?? '', desc: row[descCol] ?? '', amt };
});
const isDuplicate = (desc: string, dateStr: string, amt: number) => {
if (!skipDuplicates) return false;
const d = new Date(dateStr).toDateString();
return existingTransactions.some(t =>
t.description === desc &&
Math.abs(t.amount - amt) < 0.01 &&
new Date(t.date).toDateString() === d
);
};
const handleImport = () => {
if (!dateCol || !descCol || (!splitAmounts && !amtCol) || (splitAmounts && !amtInCol && !amtOutCol)) {
alert('Please map Date, Description, and Amount columns.');
return;
}
let skipped = 0;
const txns: ParsedTransaction[] = [];
csvRows.forEach(row => {
const amt = splitAmounts
? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? ''))
: negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]);
if (amt === 0) return;
const rawDate = row[dateCol] ?? '';
let date: Date;
try { date = new Date(rawDate); if (isNaN(date.getTime())) return; }
catch { return; }
const desc = row[descCol] ?? 'Imported transaction';
if (isDuplicate(desc, rawDate, amt)) { skipped++; return; }
txns.push({ date, amount: amt, description: desc, accountId: selectedAccountId || undefined });
});
onTransactionsParsed(txns);
if (skipped > 0) alert(`Imported ${txns.length} transactions. Skipped ${skipped} duplicates.`);
setCsvHeaders([]); setCsvRows([]); setFileName('');
};
const colOpts = ['', ...csvHeaders];
const preview = csvHeaders.length && dateCol && descCol ? buildPreview() : [];
return (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Import Bank Transactions</h3>
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Import Bank Transactions</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
File Format
</label>
<select
value={fileType}
onChange={(e) => setFileType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{supportedFormats.map((format) => (
<option key={format.value} value={format.value}>
{format.label}
</option>
))}
</select>
</div>
{/* Account selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Import to Account</label>
<select value={selectedAccountId} onChange={e => setSelectedAccountId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Select an account…</option>
{accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select File
{/* File upload */}
{!csvHeaders.length ? (
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-blue-400 bg-gray-50 transition-colors">
<div className="text-3xl mb-1">📁</div>
<p className="text-sm font-medium text-gray-600">Click to upload CSV / OFX / QFX</p>
<input type="file" accept=".csv,.ofx,.qfx,.qbo,text/csv" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
</label>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
<span className="text-sm font-medium text-green-800">✓ {fileName} — {csvRows.length} rows</span>
<button onClick={() => { setCsvHeaders([]); setCsvRows([]); setFileName(''); }}
className="text-xs text-red-600 hover:text-red-800">Remove</button>
</div>
{/* Column mapping */}
<div>
<p className="text-sm font-semibold text-gray-800 mb-2">Column Mapping</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Date <span className="text-red-500">*</span></label>
<select value={dateCol} onChange={e => setDateCol(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Description <span className="text-red-500">*</span></label>
<select value={descCol} onChange={e => setDescCol(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
</select>
</div>
</div>
<div className="mt-3 flex items-center gap-3">
<span className="text-xs font-medium text-gray-700">Amount:</span>
{(['single', 'split'] as const).map(m => (
<button key={m} onClick={() => setSplitAmounts(m === 'split')}
className={`px-3 py-1 text-xs rounded-full border ${(m === 'split') === splitAmounts ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-300'}`}>
{m === 'single' ? 'Single column' : 'Debit/Credit columns'}
</button>
))}
</div>
{!splitAmounts ? (
<div className="mt-2 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Amount column <span className="text-red-500">*</span></label>
<select value={amtCol} onChange={e => setAmtCol(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
</select>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input type="checkbox" checked={negateAmt} onChange={e => setNegateAmt(e.target.checked)} className="rounded" />
Negate (flip +/−)
</label>
</div>
</div>
) : (
<div className="mt-2 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Credit / Money In</label>
<select value={amtInCol} onChange={e => setAmtInCol(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Debit / Money Out</label>
<select value={amtOutCol} onChange={e => setAmtOutCol(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
</select>
</div>
</div>
)}
</div>
{/* Options */}
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="checkbox" checked={skipDuplicates} onChange={e => setSkipDuplicates(e.target.checked)} className="rounded" />
Skip duplicates (same date, description, and amount)
</label>
<input
id="file-upload"
type="file"
accept={supportedFormats.find(f => f.value === fileType)?.extensions}
onChange={handleFileChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{selectedFile && (
<p className="mt-2 text-sm text-gray-600">
Selected: {selectedFile.name}
</p>
{/* Preview */}
{preview.length > 0 && (
<div>
<p className="text-sm font-semibold text-gray-800 mb-2">Preview (first 5 rows)</p>
<div className="overflow-x-auto rounded border border-gray-200">
<table className="w-full text-xs">
<thead className="bg-gray-50">
<tr>
<th className="px-2 py-1.5 text-left text-gray-600 font-medium">Date</th>
<th className="px-2 py-1.5 text-left text-gray-600 font-medium">Description</th>
<th className="px-2 py-1.5 text-right text-gray-600 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{preview.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-2 py-1.5 text-gray-700">{row.date}</td>
<td className="px-2 py-1.5 text-gray-700 max-w-[200px] truncate">{row.desc}</td>
<td className={`px-2 py-1.5 text-right font-medium ${row.amt >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{row.amt >= 0 ? '+' : ''}${Math.abs(row.amt).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-1">{csvRows.length} total transactions</p>
</div>
)}
<button onClick={handleImport}
className="w-full bg-blue-600 text-white py-2.5 px-4 rounded-lg hover:bg-blue-700 font-medium text-sm">
Import {csvRows.length} Transactions
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Import to Account
</label>
<select
value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select an account...</option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</select>
</div>
<button
onClick={handleUpload}
disabled={!selectedFile || isProcessing || !selectedAccountId}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isProcessing ? 'Processing...' : 'Upload & Import'}
</button>
</div>
<div className="mt-4 text-sm text-gray-600">
<p className="font-medium mb-2">Supported formats:</p>
<ul className="list-disc list-inside space-y-1">
<li><strong>CSV:</strong> Date, Amount, Description columns</li>
<li><strong>OFX/QFX:</strong> Standard bank export formats</li>
<li><strong>QBO:</strong> QuickBooks Online format</li>
<li><strong>PDF:</strong> Coming soon</li>
</ul>
</div>
)}
</div>
);
}
}
+24
View File
@@ -22,6 +22,8 @@ interface ManualEntryProps {
date: Date;
envelopeId?: string;
accountId: string;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
}) => void;
envelopes: Envelope[];
accounts: { id: string; name: string }[];
@@ -35,6 +37,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
const [selectedEnvelope, setSelectedEnvelope] = useState('');
const [selectedAccount, setSelectedAccount] = useState(accounts?.[0]?.id || '');
const [isExpense, setIsExpense] = useState(true);
const [isRecurring, setIsRecurring] = useState(false);
const [recurringFrequency, setRecurringFrequency] = useState<'weekly' | 'biweekly' | 'monthly' | 'yearly'>('monthly');
// Update selected account when accounts change
useEffect(() => {
@@ -88,6 +92,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
date: new Date(date),
envelopeId: selectedEnvelope || undefined,
accountId: selectedAccount,
isRecurring: isRecurring || undefined,
recurringFrequency: isRecurring ? recurringFrequency : undefined,
});
// Reset form
@@ -96,6 +102,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
setDate(new Date().toISOString().split('T')[0]);
setSelectedEnvelope('');
setSelectedAccount(accounts?.[0]?.id || '');
setIsRecurring(false);
};
if (!accounts || accounts.length === 0) {
@@ -237,6 +244,23 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
</label>
</div>
{/* Recurring toggle */}
<div className="p-3 bg-gray-50 rounded-md space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isRecurring} onChange={e => setIsRecurring(e.target.checked)} className="rounded" />
<span className="text-sm font-medium text-gray-700">↻ Make this a recurring transaction</span>
</label>
{isRecurring && (
<select value={recurringFrequency} onChange={e => setRecurringFrequency(e.target.value as typeof recurringFrequency)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly (every 2 weeks)</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
)}
</div>
<button
type="submit"
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
+54 -67
View File
@@ -15,40 +15,57 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
const [deleteInput, setDeleteInput] = useState('');
const [clearDataInput, setClearDataInput] = useState('');
const [resetEmail, setResetEmail] = useState('');
const [resetCode, setResetCode] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loading, setLoading] = useState(false);
const handlePasswordReset = async (e: React.FormEvent) => {
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
if (newPassword !== confirmPassword) {
setMessage({ type: 'error', text: 'New passwords do not match.' });
return;
}
if (newPassword.length < 8) {
setMessage({ type: 'error', text: 'New password must be at least 8 characters.' });
return;
}
setLoading(true);
try {
// In a real app, this would send an email with a reset code
// For now, we'll show a simplified flow
const response = await fetch('/api/auth/reset-password', {
// Verify current password by attempting login
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: resetEmail || user.email, code: resetCode, newPassword }),
body: JSON.stringify({ email: user.email, password: currentPassword }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Password reset failed');
if (!loginRes.ok) {
setMessage({ type: 'error', text: 'Current password is incorrect.' });
return;
}
setMessage({ type: 'success', text: 'Password reset successfully! Please sign in again.' });
setResetCode('');
setNewPassword('');
// Generate a reset token and use it to set the new password
const tokenRes = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'request', email: user.email }),
});
if (!tokenRes.ok) throw new Error('Failed to initiate password change');
const { token } = await tokenRes.json();
const resetRes = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reset', token, newPassword }),
});
if (!resetRes.ok) {
const err = await resetRes.json();
throw new Error(err.error || 'Password change failed');
}
setMessage({ type: 'success', text: 'Password changed successfully! Signing out...' });
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('');
setTimeout(() => AuthService.signOut(), 2000);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Password reset failed',
});
setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Password change failed' });
} finally {
setLoading(false);
}
@@ -106,11 +123,12 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
setLoading(true);
try {
const token = AuthService.getSessionToken();
const response = await fetch('/api/auth/delete-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.userId,
'x-session-token': token ?? '',
},
});
@@ -206,61 +224,30 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
{/* Password Tab */}
{activeTab === 'password' && (
<div className="bg-white rounded-lg shadow-sm border p-6 max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Reset Password</h3>
<form onSubmit={handlePasswordReset} className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">Change Password</h3>
<p className="text-sm text-gray-500 mb-4">Signed in as <strong>{user.email}</strong></p>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
value={resetEmail || user.email}
onChange={(e) => setResetEmail(e.target.value)}
<label className="block text-sm font-medium text-gray-700 mb-1">Current Password</label>
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="your@email.com"
/>
placeholder="••••••••" required />
</div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-800">
<p className="font-medium mb-1">Reset Code</p>
<p>
In a production app, a reset code would be sent to your email. For testing, enter any code and your new password.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Reset Code
</label>
<input
type="text"
value={resetCode}
onChange={(e) => setResetCode(e.target.value)}
<label className="block text-sm font-medium text-gray-700 mb-1">New Password</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Enter code from email"
/>
placeholder="Min. 8 characters" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="••••••••"
/>
placeholder="••••••••" required />
</div>
<button
type="submit"
disabled={loading || !resetCode || !newPassword}
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 disabled:opacity-50 transition-opacity"
style={{ backgroundColor: 'var(--color-primary-blue)' }}
>
{loading ? 'Resetting...' : 'Reset Password'}
<button type="submit" disabled={loading || !currentPassword || !newPassword || !confirmPassword}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-opacity">
{loading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
+1 -1
View File
@@ -461,7 +461,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
const amtCol = find('amount', 'amt', 'debit/credit', 'transaction amount');
const inCol = find('credit', 'deposit', 'money in', 'additions');
const outCol = find('debit', 'withdrawal', 'money out', 'subtractions');
const hasSplit = !amtCol && (inCol || outCol);
const hasSplit = !amtCol && !!(inCol || outCol);
setCsvMapping(m => ({
...m,