Major feature release: security hardening + Goals, Rules, search, charts
Security: - Replace SHA-256 password hashing with bcrypt (cost 12) + timing-safe compare - Introduce server-side session tokens (x-session-token header); drop x-user-id - Add /api/auth/logout endpoint to invalidate sessions - Enforce 8-char minimum password server-side - Fix password reset: now uses real signed tokens with 1-hour TTL Features: - Goals: savings goals with progress bars, target dates, add/withdraw funds - Auto-categorization Rules: keyword → envelope mapping applied on transaction add - Transaction search/filter: description, account, envelope, date range - Transaction table: shows envelope color dot, running balance column - Monthly Spending History chart (last 6 months income vs expenses bar chart) - Spending Insights: month-over-month comparison + overspent envelope callouts - Budget progress expanded: per-envelope bars with rollover indicator - Envelope rollover toggle: unspent funds can carry forward - Recurring transactions auto-applied on login - Save indicator in nav (Saving… / Saved ✓ / Save failed) - Nav overflow fixed: all tabs scroll horizontally instead of clipping - PWA manifest added for home-screen install support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
8814423fa6
commit
20ea25f4de
19 files changed
+1673
-1474
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "budget-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 7654
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+17
@@ -8,6 +8,8 @@
|
||||
"name": "budget",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
@@ -1488,6 +1490,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2421,6 +2429,15 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Capsule - Smart Envelope Budgeting",
|
||||
"short_name": "Capsule",
|
||||
"description": "Track spending with smart envelopes and auto-income allocation",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f9fafb",
|
||||
"theme_color": "#1E73BE",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/capsule-logo.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["finance", "productivity"],
|
||||
"lang": "en"
|
||||
}
|
||||
@@ -2,24 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const token = request.headers.get('x-session-token');
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const session = await FileStorageService.validateSession(token);
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const userId = request.headers.get('x-user-id');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User ID is required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete user account and all data
|
||||
await FileStorageService.deleteUser(userId);
|
||||
|
||||
await FileStorageService.deleteUser(session.userId, token);
|
||||
return NextResponse.json({ success: true, message: 'Account deleted successfully' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Account deletion failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: error.message || 'Account deletion failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,13 @@ export async function POST(request: NextRequest) {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await FileStorageService.loginUser(email, password);
|
||||
const result = await FileStorageService.loginUser(email, password);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user
|
||||
});
|
||||
return NextResponse.json({ success: true, user: { userId: result.userId, email: result.email }, sessionToken: result.sessionToken });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Login failed' },
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: error.message || 'Login failed' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const token = request.headers.get('x-session-token');
|
||||
if (token) await FileStorageService.deleteSession(token);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -6,22 +6,14 @@ export async function POST(request: NextRequest) {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await FileStorageService.registerUser(email, password);
|
||||
const result = await FileStorageService.registerUser(email, password);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user
|
||||
});
|
||||
return NextResponse.json({ success: true, user: { userId: result.userId, email: result.email }, sessionToken: result.sessionToken });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Registration failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const status = error.message === 'User already exists' ? 409 : 400;
|
||||
return NextResponse.json({ error: error.message || 'Registration failed' }, { status });
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
// POST /api/auth/reset-password/request — generate a reset token for an email
|
||||
// POST /api/auth/reset-password — consume a token and set a new password
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, code, newPassword } = await request.json();
|
||||
const body = await request.json();
|
||||
|
||||
if (!email || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and new password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (body.action === 'request') {
|
||||
const { email } = body;
|
||||
if (!email) return NextResponse.json({ error: 'Email is required' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const token = await FileStorageService.generatePasswordResetToken(email);
|
||||
// In production this token would be emailed. For self-hosted use we return it directly.
|
||||
return NextResponse.json({ success: true, resetToken: token, message: 'Use the resetToken to set your new password.' });
|
||||
} catch {
|
||||
// Return success either way to avoid email enumeration
|
||||
return NextResponse.json({ success: true, message: 'If that email exists, a reset token has been generated.' });
|
||||
}
|
||||
}
|
||||
|
||||
// In production, validate that code matches a sent reset token
|
||||
// For now, we accept any code for testing purposes
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Reset code is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
// action === 'reset' (default)
|
||||
const { token, newPassword } = body;
|
||||
if (!token || !newPassword) {
|
||||
return NextResponse.json({ error: 'Token and new password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Reset password for user
|
||||
await FileStorageService.resetPassword(email, newPassword);
|
||||
|
||||
await FileStorageService.resetPassword(token, newPassword);
|
||||
return NextResponse.json({ success: true, message: 'Password reset successfully' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Password reset failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: error.message || 'Password reset failed' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
+18
-37
@@ -1,52 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
async function authenticate(request: NextRequest) {
|
||||
const token = request.headers.get('x-session-token');
|
||||
if (!token) return null;
|
||||
return FileStorageService.validateSession(token);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await authenticate(request);
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const userId = request.headers.get('x-user-id');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User ID is required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await FileStorageService.loadUserData(userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data
|
||||
});
|
||||
const data = await FileStorageService.loadUserData(session.userId);
|
||||
return NextResponse.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to load data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: error.message || 'Failed to load data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await authenticate(request);
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const userId = request.headers.get('x-user-id');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User ID is required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
await FileStorageService.saveUserData(userId, data);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true
|
||||
});
|
||||
await FileStorageService.saveUserData(session.userId, data);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to save data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: error.message || 'Failed to save data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: "/capsule-icon.svg",
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Capsule",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+833
-922
File diff suppressed because it is too large.
Load diff
@@ -301,6 +301,56 @@ export default function DashboardCharts({ accounts, envelopes, transactions, vis
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly Spending History (Last 6 Months) */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Monthly Spending History (Last 6 Months)</h3>
|
||||
{(() => {
|
||||
const months = Array.from({ length: 6 }, (_, i) => {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - (5 - i), 1);
|
||||
return { year: d.getFullYear(), month: d.getMonth(), label: d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }) };
|
||||
});
|
||||
const monthlyData = months.map(m => {
|
||||
const start = new Date(m.year, m.month, 1);
|
||||
const end = new Date(m.year, m.month + 1, 0, 23, 59, 59);
|
||||
const income = transactions.filter(t => {
|
||||
const d = t.date instanceof Date ? t.date : new Date(t.date);
|
||||
return t.amount > 0 && !t.envelopeId && d >= start && d <= end;
|
||||
}).reduce((s, t) => s + t.amount, 0);
|
||||
const spent = transactions.filter(t => {
|
||||
const d = t.date instanceof Date ? t.date : new Date(t.date);
|
||||
return t.amount < 0 && d >= start && d <= end;
|
||||
}).reduce((s, t) => s + Math.abs(t.amount), 0);
|
||||
return { ...m, income, spent };
|
||||
});
|
||||
const maxVal = Math.max(...monthlyData.map(m => Math.max(m.income, m.spent)), 1);
|
||||
return monthlyData.some(m => m.income > 0 || m.spent > 0) ? (
|
||||
<div>
|
||||
<div className="flex items-end justify-between h-40 gap-3">
|
||||
{monthlyData.map(m => (
|
||||
<div key={m.label} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full flex items-end gap-0.5 h-32">
|
||||
<div className="flex-1 bg-green-400 rounded-t hover:bg-green-500 transition-colors relative group"
|
||||
style={{ height: `${(m.income / maxVal) * 100}%`, minHeight: m.income > 0 ? '4px' : '0' }}>
|
||||
{m.income > 0 && <div className="absolute -top-6 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs py-0.5 px-1 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap">${m.income.toFixed(0)}</div>}
|
||||
</div>
|
||||
<div className="flex-1 bg-red-400 rounded-t hover:bg-red-500 transition-colors relative group"
|
||||
style={{ height: `${(m.spent / maxVal) * 100}%`, minHeight: m.spent > 0 ? '4px' : '0' }}>
|
||||
{m.spent > 0 && <div className="absolute -top-6 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs py-0.5 px-1 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap">${m.spent.toFixed(0)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{m.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 justify-center text-xs text-gray-600">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-green-400 inline-block" />Income</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-red-400 inline-block" />Expenses</span>
|
||||
</div>
|
||||
</div>
|
||||
) : <p className="text-gray-500 text-sm">No data yet. Add transactions to see history.</p>;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Daily Spending Chart (Last 7 Days) */}
|
||||
{visibleCharts.dailySpending && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 lg:col-span-2">
|
||||
|
||||
+87
-163
@@ -2,41 +2,35 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
rollover?: boolean;
|
||||
}
|
||||
|
||||
interface EnvelopeEditProps {
|
||||
envelope: {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
};
|
||||
envelope: Envelope;
|
||||
accounts: { id: string; name: string }[];
|
||||
onEnvelopeUpdated: (envelope: {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
}) => void;
|
||||
onEnvelopeUpdated: (envelope: Envelope) => void;
|
||||
onEnvelopeDeleted?: (envelopeId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'bg-green-500', label: 'Green', color: '#10B981' },
|
||||
{ value: 'bg-blue-500', label: 'Blue', color: '#3B82F6' },
|
||||
{ value: 'bg-purple-500', label: 'Purple', color: '#8B5CF6' },
|
||||
{ value: 'bg-orange-500', label: 'Orange', color: '#F97316' },
|
||||
{ value: 'bg-red-500', label: 'Red', color: '#EF4444' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow', color: '#EAB308' },
|
||||
{ value: 'bg-pink-500', label: 'Pink', color: '#EC4899' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo', color: '#6366F1' },
|
||||
{ label: 'Green', color: '#10B981' },
|
||||
{ label: 'Blue', color: '#3B82F6' },
|
||||
{ label: 'Purple', color: '#8B5CF6' },
|
||||
{ label: 'Orange', color: '#F97316' },
|
||||
{ label: 'Red', color: '#EF4444' },
|
||||
{ label: 'Yellow', color: '#EAB308' },
|
||||
{ label: 'Pink', color: '#EC4899' },
|
||||
{ label: 'Indigo', color: '#6366F1' },
|
||||
];
|
||||
|
||||
export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDeleted, onCancel, accounts }: EnvelopeEditProps) {
|
||||
@@ -46,8 +40,8 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
|
||||
const [selectedAccount, setSelectedAccount] = useState(envelope.accountId);
|
||||
const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage');
|
||||
const [incomeAllocation, setIncomeAllocation] = useState(envelope.incomeAllocation?.toString() || '');
|
||||
const [rollover, setRollover] = useState(envelope.rollover ?? false);
|
||||
|
||||
// Sync form fields when a different envelope is opened for editing
|
||||
useEffect(() => {
|
||||
setName(envelope.name);
|
||||
setAllocated(envelope.allocated.toString());
|
||||
@@ -55,40 +49,29 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
|
||||
setSelectedAccount(envelope.accountId);
|
||||
setIncomeAllocationType(envelope.incomeAllocationType || 'percentage');
|
||||
setIncomeAllocation(envelope.incomeAllocation?.toString() || '');
|
||||
setRollover(envelope.rollover ?? false);
|
||||
}, [envelope]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(`Are you sure you want to delete the envelope "${envelope.name}"? Transactions linked to this envelope will be unassigned.`)) {
|
||||
if (onEnvelopeDeleted) {
|
||||
onEnvelopeDeleted(envelope.id);
|
||||
}
|
||||
if (confirm(`Delete envelope "${envelope.name}"? Transactions will be unassigned.`)) {
|
||||
onEnvelopeDeleted?.(envelope.id);
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const numAllocated = parseFloat(allocated);
|
||||
if (!name.trim() || isNaN(numAllocated) || numAllocated <= 0) {
|
||||
alert('Please enter a valid envelope name and positive allocated amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate income allocation
|
||||
if (incomeAllocation) {
|
||||
const allocationValue = parseFloat(incomeAllocation);
|
||||
if (isNaN(allocationValue) || allocationValue < 0) {
|
||||
alert('Please enter a valid income allocation amount');
|
||||
return;
|
||||
}
|
||||
if (incomeAllocationType === 'percentage' && allocationValue > 100) {
|
||||
alert('Percentage allocation cannot exceed 100%');
|
||||
return;
|
||||
}
|
||||
const v = parseFloat(incomeAllocation);
|
||||
if (isNaN(v) || v < 0) { alert('Please enter a valid income allocation amount'); return; }
|
||||
if (incomeAllocationType === 'percentage' && v > 100) { alert('Percentage allocation cannot exceed 100%'); return; }
|
||||
}
|
||||
|
||||
const updatedEnvelope = {
|
||||
onEnvelopeUpdated({
|
||||
...envelope,
|
||||
name: name.trim(),
|
||||
allocated: numAllocated,
|
||||
@@ -96,163 +79,104 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
|
||||
accountId: selectedAccount,
|
||||
incomeAllocation: incomeAllocation ? parseFloat(incomeAllocation) : undefined,
|
||||
incomeAllocationType: incomeAllocation ? incomeAllocationType : undefined,
|
||||
};
|
||||
|
||||
onEnvelopeUpdated(updatedEnvelope);
|
||||
rollover,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Edit Envelope</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Envelope Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Envelope Name</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g., Groceries, Transportation"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Allocated Amount
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Allocated Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={allocated}
|
||||
onChange={(e) => setAllocated(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input type="number" step="0.01" min="0" value={allocated} onChange={e => setAllocated(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
className={`w-12 h-12 rounded-full border-2 ${
|
||||
selectedColor === color.value ? 'border-gray-800' : 'border-gray-300'
|
||||
}`}
|
||||
style={{ backgroundColor: color.color }}
|
||||
title={color.label}
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Color</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colorOptions.map(c => (
|
||||
<button key={c.color} type="button" onClick={() => setSelectedColor(c.color)}
|
||||
className={`w-10 h-10 rounded-full border-2 transition-transform ${selectedColor === c.color ? 'border-gray-800 scale-110' : 'border-gray-200'}`}
|
||||
style={{ backgroundColor: c.color }} title={c.label} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Selected: {colorOptions.find(c => c.value === selectedColor)?.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Account
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(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
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account</label>
|
||||
<select value={selectedAccount} onChange={e => setSelectedAccount(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>
|
||||
{accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rollover toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Roll over unspent funds</p>
|
||||
<p className="text-xs text-gray-500">Unspent money carries into next month instead of resetting.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setRollover(v => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${rollover ? 'bg-blue-600' : 'bg-gray-300'}`}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rollover ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Income Allocation (Optional)
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Income Allocation (Optional)</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="percentage"
|
||||
checked={incomeAllocationType === 'percentage'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Percentage
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="fixed"
|
||||
checked={incomeAllocationType === 'fixed'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Fixed Amount
|
||||
</label>
|
||||
{(['percentage', 'fixed'] as const).map(type => (
|
||||
<label key={type} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="allocationType" value={type}
|
||||
checked={incomeAllocationType === type} onChange={() => setIncomeAllocationType(type)} />
|
||||
{type === 'percentage' ? 'Percentage' : 'Fixed Amount'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
{incomeAllocationType === 'percentage' ? '%' : '$'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step={incomeAllocationType === 'percentage' ? '1' : '0.01'}
|
||||
min="0"
|
||||
max={incomeAllocationType === 'percentage' ? '100' : undefined}
|
||||
value={incomeAllocation}
|
||||
onChange={(e) => setIncomeAllocation(e.target.value)}
|
||||
<input type="number" step={incomeAllocationType === 'percentage' ? '1' : '0.01'}
|
||||
min="0" max={incomeAllocationType === 'percentage' ? '100' : undefined}
|
||||
value={incomeAllocation} onChange={e => setIncomeAllocation(e.target.value)}
|
||||
placeholder={incomeAllocationType === 'percentage' ? 'e.g., 25' : 'e.g., 100.00'}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{incomeAllocationType === 'percentage'
|
||||
? 'Percentage of income to allocate to this envelope automatically'
|
||||
: 'Fixed dollar amount to allocate to this envelope automatically'
|
||||
}
|
||||
? 'Percentage of income to auto-allocate here'
|
||||
: 'Fixed dollar amount to auto-allocate here'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Delete Envelope
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={handleDelete}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 text-sm font-medium">
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Update Envelope
|
||||
<button type="submit"
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
<button type="button" onClick={onCancel}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Goal } from '../app/page';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface GoalsManagerProps {
|
||||
goals: Goal[];
|
||||
accounts: Account[];
|
||||
onGoalsChange: (goals: Goal[]) => void;
|
||||
}
|
||||
|
||||
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'];
|
||||
|
||||
export default function GoalsManager({ goals, accounts, onGoalsChange }: GoalsManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<Goal | null>(null);
|
||||
const [form, setForm] = useState({ name: '', targetAmount: '', currentAmount: '', accountId: '', targetDate: '', color: COLORS[0] });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ name: '', targetAmount: '', currentAmount: '', accountId: accounts[0]?.id ?? '', targetDate: '', color: COLORS[0] });
|
||||
setEditingGoal(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (g: Goal) => {
|
||||
setForm({
|
||||
name: g.name, targetAmount: g.targetAmount.toString(), currentAmount: g.currentAmount.toString(),
|
||||
accountId: g.accountId, targetDate: g.targetDate ?? '', color: g.color,
|
||||
});
|
||||
setEditingGoal(g);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name.trim() || !form.targetAmount) { setError('Name and target amount are required.'); return; }
|
||||
const goal: Goal = {
|
||||
id: editingGoal?.id ?? crypto.randomUUID(),
|
||||
name: form.name.trim(),
|
||||
targetAmount: parseFloat(form.targetAmount),
|
||||
currentAmount: parseFloat(form.currentAmount || '0'),
|
||||
accountId: form.accountId,
|
||||
targetDate: form.targetDate || undefined,
|
||||
color: form.color,
|
||||
};
|
||||
onGoalsChange(editingGoal
|
||||
? goals.map(g => g.id === editingGoal.id ? goal : g)
|
||||
: [...goals, goal]);
|
||||
setShowForm(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('Delete this goal?')) onGoalsChange(goals.filter(g => g.id !== id));
|
||||
};
|
||||
|
||||
const updateAmount = (goal: Goal, delta: number) => {
|
||||
const next = Math.max(0, goal.currentAmount + delta);
|
||||
onGoalsChange(goals.map(g => g.id === goal.id ? { ...g, currentAmount: next } : g));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Goals</h2>
|
||||
<button onClick={openCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||
+ Add Goal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{goals.length === 0 && !showForm && (
|
||||
<div className="text-center py-16 bg-white rounded-lg shadow">
|
||||
<div className="text-4xl mb-3">🎯</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">No goals yet</h3>
|
||||
<p className="text-gray-500 text-sm mb-4">Create a savings goal and track your progress.</p>
|
||||
<button onClick={openCreate} className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium">Add your first goal</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">{editingGoal ? 'Edit Goal' : 'New Goal'}</h3>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Goal name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g. Emergency Fund, Vacation"
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Target amount ($)</label>
|
||||
<input type="number" min="0" step="0.01" value={form.targetAmount} onChange={e => setForm(f => ({ ...f, targetAmount: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current amount ($)</label>
|
||||
<input type="number" min="0" step="0.01" value={form.currentAmount} onChange={e => setForm(f => ({ ...f, currentAmount: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Linked account (optional)</label>
|
||||
<select value={form.accountId} onChange={e => setForm(f => ({ ...f, accountId: 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="">None</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-1">Target date (optional)</label>
|
||||
<input type="date" value={form.targetDate} onChange={e => setForm(f => ({ ...f, targetDate: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Color</label>
|
||||
<div className="flex gap-2 flex-wrap mt-1">
|
||||
{COLORS.map(c => (
|
||||
<button key={c} type="button" onClick={() => setForm(f => ({ ...f, color: c }))}
|
||||
className={`w-6 h-6 rounded-full border-2 ${form.color === c ? 'border-gray-800 scale-110' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-5">
|
||||
<button onClick={handleSave} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">Save</button>
|
||||
<button onClick={() => { setShowForm(false); setError(''); }} className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{goals.map(g => {
|
||||
const pct = g.targetAmount > 0 ? Math.min(100, (g.currentAmount / g.targetAmount) * 100) : 0;
|
||||
const remaining = Math.max(0, g.targetAmount - g.currentAmount);
|
||||
const linkedAcc = accounts.find(a => a.id === g.accountId);
|
||||
const daysLeft = g.targetDate ? Math.ceil((new Date(g.targetDate).getTime() - Date.now()) / 86400000) : null;
|
||||
|
||||
return (
|
||||
<div key={g.id} className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="h-1.5" style={{ backgroundColor: g.color }} />
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{g.name}</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openEdit(g)} className="text-indigo-600 hover:text-indigo-900 text-xs">Edit</button>
|
||||
<button onClick={() => handleDelete(g.id)} className="text-red-500 hover:text-red-800 text-xs">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>${g.currentAmount.toFixed(2)} saved</span>
|
||||
<span>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 rounded-full h-3">
|
||||
<div className="h-3 rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: g.color }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>${remaining.toFixed(2)} to go</span>
|
||||
<span>Goal: ${g.targetAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkedAcc && (
|
||||
<p className="text-xs text-gray-400 mb-2">Linked to: {linkedAcc.name}</p>
|
||||
)}
|
||||
{daysLeft !== null && (
|
||||
<p className={`text-xs mb-3 ${daysLeft < 30 ? 'text-red-500' : 'text-gray-400'}`}>
|
||||
{daysLeft > 0 ? `${daysLeft} days until target` : 'Target date passed'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => {
|
||||
const v = parseFloat(prompt('Add amount ($):', '0') ?? '0');
|
||||
if (!isNaN(v) && v > 0) updateAmount(g, v);
|
||||
}}
|
||||
className="flex-1 text-xs py-1.5 bg-green-50 border border-green-200 text-green-700 rounded hover:bg-green-100">
|
||||
+ Add funds
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const v = parseFloat(prompt('Withdraw amount ($):', '0') ?? '0');
|
||||
if (!isNaN(v) && v > 0) updateAmount(g, -v);
|
||||
}}
|
||||
className="flex-1 text-xs py-1.5 bg-gray-50 border border-gray-200 text-gray-600 rounded hover:bg-gray-100">
|
||||
− Withdraw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { TransactionRule } from '../app/page';
|
||||
|
||||
interface Envelope { id: string; name: string; color: string }
|
||||
|
||||
interface Props {
|
||||
rules: TransactionRule[];
|
||||
envelopes: Envelope[];
|
||||
onRulesChange: (rules: TransactionRule[]) => void;
|
||||
}
|
||||
|
||||
export default function TransactionRulesManager({ rules, envelopes, onRulesChange }: Props) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [envelopeId, setEnvelopeId] = useState(envelopes[0]?.id ?? '');
|
||||
const [matchCase, setMatchCase] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!keyword.trim()) { setError('Keyword is required.'); return; }
|
||||
if (!envelopeId) { setError('Select an envelope.'); return; }
|
||||
onRulesChange([...rules, { id: crypto.randomUUID(), keyword: keyword.trim(), envelopeId, matchCase }]);
|
||||
setKeyword(''); setError('');
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => onRulesChange(rules.filter(r => r.id !== id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Auto-Categorization Rules</h2>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
When a new expense is added, if its description contains a keyword below, it's automatically assigned to the linked envelope.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h3 className="text-base font-medium text-gray-900 mb-4">Add a rule</h3>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-40">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Keyword</label>
|
||||
<input type="text" value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
placeholder='e.g. "Whole Foods", "Netflix"'
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-40">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Assign to envelope</label>
|
||||
<select value={envelopeId} onChange={e => setEnvelopeId(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">
|
||||
{envelopes.map(e => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-0.5">
|
||||
<input type="checkbox" id="matchCase" checked={matchCase} onChange={e => setMatchCase(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 rounded" />
|
||||
<label htmlFor="matchCase" className="text-sm text-gray-600 whitespace-nowrap">Match case</label>
|
||||
</div>
|
||||
<button onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium whitespace-nowrap">
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow">
|
||||
<div className="text-3xl mb-2">⚡</div>
|
||||
<p className="text-gray-500 text-sm">No rules yet. Add one above to auto-categorize transactions.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Keyword</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Envelope</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Case</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{rules.map(r => {
|
||||
const env = envelopes.find(e => e.id === r.envelopeId);
|
||||
return (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono text-gray-900">"{r.keyword}"</td>
|
||||
<td className="px-4 py-3">
|
||||
{env ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: env.color }} />
|
||||
{env.name}
|
||||
</span>
|
||||
) : <span className="text-red-400 italic">Deleted envelope</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{r.matchCase ? 'Sensitive' : 'Insensitive'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => handleDelete(r.id)} className="text-red-500 hover:text-red-800 text-xs font-medium">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg text-sm text-blue-800">
|
||||
<strong>How rules work:</strong> When you add a new expense, its description is checked against all rules in order.
|
||||
The first matching rule wins. Rules only apply to expenses (negative amounts) without an already-assigned envelope.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+70
-100
@@ -1,4 +1,4 @@
|
||||
// Local authentication service (no Firebase needed)
|
||||
'use client';
|
||||
|
||||
export interface User {
|
||||
userId: string;
|
||||
@@ -7,119 +7,80 @@ export interface User {
|
||||
|
||||
export class AuthService {
|
||||
private static currentUser: User | null = null;
|
||||
private static sessionToken: string | null = null;
|
||||
private static authCallbacks: Set<(user: User | null) => void> = new Set();
|
||||
|
||||
static async signUp(email: string, password: string): Promise<User> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Registration failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.currentUser = result.user;
|
||||
|
||||
// Store in localStorage for persistence
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('user', JSON.stringify(result.user));
|
||||
}
|
||||
|
||||
// Notify all listeners
|
||||
this.notifyAuthChange(result.user);
|
||||
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
// Only log non-authentication errors to avoid console spam
|
||||
if (!(error instanceof Error) || !error.message.includes('User already exists')) {
|
||||
console.error('Sign up error:', error);
|
||||
}
|
||||
throw error;
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Registration failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this._persist(result.user, result.sessionToken);
|
||||
this.notifyAuthChange(result.user);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
static async signIn(email: string, password: string): Promise<User> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
// Don't log authentication errors to console as they may contain sensitive info
|
||||
throw new Error(error.error || 'Login failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.currentUser = result.user;
|
||||
|
||||
// Store in localStorage for persistence
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('user', JSON.stringify(result.user));
|
||||
}
|
||||
|
||||
// Notify all listeners
|
||||
this.notifyAuthChange(result.user);
|
||||
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
// Only log non-authentication errors to avoid console spam
|
||||
if (!(error instanceof Error) || !error.message.includes('Invalid credentials')) {
|
||||
console.error('Sign in error:', error);
|
||||
}
|
||||
throw error;
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Login failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this._persist(result.user, result.sessionToken);
|
||||
this.notifyAuthChange(result.user);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
static async signOut(): Promise<void> {
|
||||
this.currentUser = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userId');
|
||||
const token = this.getSessionToken();
|
||||
if (token) {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'x-session-token': token },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Notify all listeners
|
||||
this._clear();
|
||||
this.notifyAuthChange(null);
|
||||
}
|
||||
|
||||
static getCurrentUser(): User | null {
|
||||
if (this.currentUser) return this.currentUser;
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const userJson = localStorage.getItem('user');
|
||||
if (userJson) {
|
||||
try {
|
||||
this.currentUser = JSON.parse(userJson);
|
||||
return this.currentUser;
|
||||
} catch (e) {
|
||||
console.error('Error parsing stored user:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userJson) this.currentUser = JSON.parse(userJson);
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
static getSessionToken(): string | null {
|
||||
if (this.sessionToken) return this.sessionToken;
|
||||
if (typeof window === 'undefined') return null;
|
||||
this.sessionToken = localStorage.getItem('sessionToken');
|
||||
return this.sessionToken;
|
||||
}
|
||||
|
||||
static onAuthStateChanged(callback: (user: User | null) => void): () => void {
|
||||
// Add callback to listeners
|
||||
this.authCallbacks.add(callback);
|
||||
callback(this.getCurrentUser());
|
||||
|
||||
// Initial call with current user
|
||||
const user = this.getCurrentUser();
|
||||
callback(user);
|
||||
|
||||
// Set up listener for storage events (for multi-tab support)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'user') {
|
||||
if (e.newValue) {
|
||||
@@ -127,9 +88,7 @@ export class AuthService {
|
||||
const user = JSON.parse(e.newValue);
|
||||
this.currentUser = user;
|
||||
callback(user);
|
||||
} catch (err) {
|
||||
callback(null);
|
||||
}
|
||||
} catch { callback(null); }
|
||||
} else {
|
||||
this.currentUser = null;
|
||||
callback(null);
|
||||
@@ -145,18 +104,29 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.authCallbacks.delete(callback);
|
||||
};
|
||||
return () => { this.authCallbacks.delete(callback); };
|
||||
}
|
||||
|
||||
private static _persist(user: User, token: string): void {
|
||||
this.currentUser = user;
|
||||
this.sessionToken = token;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
localStorage.setItem('sessionToken', token);
|
||||
}
|
||||
}
|
||||
|
||||
private static _clear(): void {
|
||||
this.currentUser = null;
|
||||
this.sessionToken = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('sessionToken');
|
||||
localStorage.removeItem('userId');
|
||||
}
|
||||
}
|
||||
|
||||
private static notifyAuthChange(user: User | null): void {
|
||||
this.authCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(user);
|
||||
} catch (err) {
|
||||
console.error('Error in auth callback:', err);
|
||||
}
|
||||
});
|
||||
this.authCallbacks.forEach(cb => { try { cb(user); } catch {} });
|
||||
}
|
||||
}
|
||||
+38
-80
@@ -1,4 +1,4 @@
|
||||
// Local file-based data service (no Firebase needed)
|
||||
import { AuthService } from './authService';
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
@@ -6,18 +6,18 @@ interface Envelope {
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string; // Link envelope to specific account
|
||||
incomeAllocation?: number; // Percentage (0-100) or fixed amount if negative
|
||||
incomeAllocationType?: 'percentage' | 'fixed'; // How to interpret incomeAllocation
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
envelopeId?: string;
|
||||
accountId: string; // Link transaction to specific account
|
||||
accountId: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date | string; // Support both Date objects and ISO strings
|
||||
date: Date | string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
@@ -39,93 +39,51 @@ interface UserData {
|
||||
envelopes: Envelope[];
|
||||
transactions: Transaction[];
|
||||
setupCompleted: boolean;
|
||||
goals?: any[];
|
||||
transactionRules?: any[];
|
||||
}
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = AuthService.getSessionToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
return { 'Content-Type': 'application/json', 'x-session-token': token };
|
||||
}
|
||||
|
||||
export class DataService {
|
||||
private static currentUserId: string | null = null;
|
||||
|
||||
static setUserId(userId: string | null) {
|
||||
this.currentUserId = userId;
|
||||
if (typeof window !== 'undefined') {
|
||||
if (userId) {
|
||||
localStorage.setItem('userId', userId);
|
||||
} else {
|
||||
localStorage.removeItem('userId');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getUserId(): string | null {
|
||||
if (this.currentUserId) return this.currentUserId;
|
||||
if (typeof window !== 'undefined') {
|
||||
this.currentUserId = localStorage.getItem('userId');
|
||||
}
|
||||
return this.currentUserId;
|
||||
}
|
||||
|
||||
static clearUserId() {
|
||||
this.setUserId(null);
|
||||
}
|
||||
// kept for legacy callers in page.tsx — no-op now that session handles identity
|
||||
static setUserId(_userId: string | null) {}
|
||||
static getUserId(): string | null { return AuthService.getCurrentUser()?.userId ?? null; }
|
||||
static clearUserId() {}
|
||||
|
||||
static async saveUserData(data: UserData): Promise<void> {
|
||||
const userId = this.getUserId();
|
||||
if (!userId) throw new Error('No user ID set');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/data', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-id': userId,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to save data');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving user data:', error);
|
||||
throw error;
|
||||
const response = await fetch('/api/data', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to save data');
|
||||
}
|
||||
}
|
||||
|
||||
static async loadUserData(): Promise<UserData | null> {
|
||||
const userId = this.getUserId();
|
||||
if (!userId) throw new Error('No user ID set');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/data', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-user-id': userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to load data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
throw error;
|
||||
const response = await fetch('/api/data', {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to load data');
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
static async updateUserData(updates: Partial<UserData>): Promise<void> {
|
||||
try {
|
||||
const currentData = await this.loadUserData();
|
||||
if (currentData) {
|
||||
const updatedData = { ...currentData, ...updates };
|
||||
await this.saveUserData(updatedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user data:', error);
|
||||
throw error;
|
||||
const currentData = await this.loadUserData();
|
||||
if (currentData) {
|
||||
await this.saveUserData({ ...currentData, ...updates });
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
-110
@@ -1,26 +1,26 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { encryptData, decryptData } from '@/lib/crypto';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const LOCK_DIR = path.join(DATA_DIR, 'locks');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
||||
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
|
||||
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Initialize directories
|
||||
async function initializeDirectories() {
|
||||
try {
|
||||
await fsPromises.mkdir(DATA_DIR, { recursive: true });
|
||||
await fsPromises.mkdir(LOCK_DIR, { recursive: true });
|
||||
|
||||
// Ensure users file exists
|
||||
try {
|
||||
await fsPromises.access(USERS_FILE);
|
||||
} catch {
|
||||
await fsPromises.writeFile(USERS_FILE, JSON.stringify({}), 'utf-8');
|
||||
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE]) {
|
||||
try {
|
||||
await fsPromises.access(file);
|
||||
} catch {
|
||||
await fsPromises.writeFile(file, JSON.stringify({}), 'utf-8');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing directories:', error);
|
||||
@@ -39,16 +39,31 @@ interface Users {
|
||||
[email: string]: UserCredentials;
|
||||
}
|
||||
|
||||
// Lock mechanism for concurrent access prevention
|
||||
interface Session {
|
||||
userId: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface Sessions {
|
||||
[token: string]: Session;
|
||||
}
|
||||
|
||||
interface ResetToken {
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface ResetTokens {
|
||||
[token: string]: ResetToken;
|
||||
}
|
||||
|
||||
class FileLock {
|
||||
private static locks = new Map<string, Promise<void>>();
|
||||
|
||||
static async acquire(key: string): Promise<() => void> {
|
||||
// Wait for any existing lock
|
||||
const existingLock = this.locks.get(key);
|
||||
if (existingLock) {
|
||||
await existingLock;
|
||||
}
|
||||
if (existingLock) await existingLock;
|
||||
|
||||
let releaseLock: () => void;
|
||||
const lockPromise = new Promise<void>((resolve) => {
|
||||
@@ -59,107 +74,148 @@ class FileLock {
|
||||
});
|
||||
|
||||
this.locks.set(key, lockPromise);
|
||||
|
||||
return releaseLock!;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple password hashing (in production, use bcrypt or similar)
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
// Prefer Web Crypto when available (browser/modern Node), otherwise fall back to Node crypto
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
function validatePasswordStrength(password: string): void {
|
||||
if (password.length < 8) throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
// Fallback for environments without crypto.subtle
|
||||
return createHash('sha256').update(password, 'utf8').digest('hex');
|
||||
async function readJson<T>(file: string): Promise<T> {
|
||||
const content = await fsPromises.readFile(file, 'utf-8');
|
||||
return JSON.parse(content) as T;
|
||||
}
|
||||
|
||||
async function writeJson(file: string, data: unknown): Promise<void> {
|
||||
await fsPromises.writeFile(file, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export class FileStorageService {
|
||||
// User Authentication
|
||||
static async registerUser(email: string, password: string): Promise<{ userId: string; email: string }> {
|
||||
static async registerUser(email: string, password: string): Promise<{ userId: string; email: string; sessionToken: string }> {
|
||||
validatePasswordStrength(password);
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
|
||||
const users: Users = JSON.parse(content);
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
|
||||
if (users[email]) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
if (users[email]) throw new Error('User already exists');
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const passwordHash = await hashPassword(password);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
users[email] = {
|
||||
email,
|
||||
passwordHash,
|
||||
userId,
|
||||
};
|
||||
users[email] = { email, passwordHash, userId };
|
||||
await writeJson(USERS_FILE, users);
|
||||
|
||||
await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
|
||||
const encryptedData = await encryptData(JSON.stringify({
|
||||
accounts: [], envelopes: [], transactions: [], setupCompleted: false,
|
||||
}), ENCRYPTION_KEY);
|
||||
await writeJson(path.join(DATA_DIR, `${userId}.json`), {
|
||||
data: encryptedData, lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Create user data file
|
||||
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
|
||||
const initialData = {
|
||||
accounts: [],
|
||||
envelopes: [],
|
||||
transactions: [],
|
||||
setupCompleted: false,
|
||||
};
|
||||
|
||||
const encryptedData = await encryptData(JSON.stringify(initialData), ENCRYPTION_KEY);
|
||||
await fsPromises.writeFile(userDataFile, JSON.stringify({ data: encryptedData, lastUpdated: new Date().toISOString() }), 'utf-8');
|
||||
|
||||
return { userId, email };
|
||||
const sessionToken = await this._createSession(userId, email);
|
||||
return { userId, email, sessionToken };
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string }> {
|
||||
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
|
||||
const users: Users = JSON.parse(content);
|
||||
|
||||
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string; sessionToken: string }> {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const user = users[email];
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
if (passwordHash !== user.passwordHash) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
// Always run bcrypt compare to prevent timing attacks
|
||||
const hashToCompare = user?.passwordHash ?? '$2a$12$invalidhashpadding000000000000000000000000000000000000000';
|
||||
const valid = await bcrypt.compare(password, hashToCompare);
|
||||
|
||||
return { userId: user.userId, email: user.email };
|
||||
if (!user || !valid) throw new Error('Invalid credentials');
|
||||
|
||||
const sessionToken = await this._createSession(user.userId, email);
|
||||
return { userId: user.userId, email: user.email, sessionToken };
|
||||
}
|
||||
|
||||
static async validateSession(token: string): Promise<{ userId: string; email: string } | null> {
|
||||
try {
|
||||
const sessions = await readJson<Sessions>(SESSIONS_FILE);
|
||||
const session = sessions[token];
|
||||
if (!session) return null;
|
||||
if (new Date(session.expiresAt) < new Date()) {
|
||||
// Expired — clean up lazily
|
||||
delete sessions[token];
|
||||
await writeJson(SESSIONS_FILE, sessions);
|
||||
return null;
|
||||
}
|
||||
return { userId: session.userId, email: session.email };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteSession(token: string): Promise<void> {
|
||||
try {
|
||||
const sessions = await readJson<Sessions>(SESSIONS_FILE);
|
||||
delete sessions[token];
|
||||
await writeJson(SESSIONS_FILE, sessions);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
static async generatePasswordResetToken(email: string): Promise<string> {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
if (!users[email]) throw new Error('User not found');
|
||||
|
||||
const releaseLock = await FileLock.acquire('reset-tokens');
|
||||
try {
|
||||
const tokens = await readJson<ResetTokens>(RESET_TOKENS_FILE);
|
||||
const token = crypto.randomUUID();
|
||||
tokens[token] = { email, expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString() };
|
||||
await writeJson(RESET_TOKENS_FILE, tokens);
|
||||
return token;
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
static async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||
validatePasswordStrength(newPassword);
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
|
||||
try {
|
||||
const tokens = await readJson<ResetTokens>(RESET_TOKENS_FILE);
|
||||
const record = tokens[token];
|
||||
if (!record) throw new Error('Invalid or expired reset token');
|
||||
if (new Date(record.expiresAt) < new Date()) throw new Error('Reset token has expired');
|
||||
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
if (!users[record.email]) throw new Error('User not found');
|
||||
|
||||
users[record.email].passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await writeJson(USERS_FILE, users);
|
||||
|
||||
delete tokens[token];
|
||||
await writeJson(RESET_TOKENS_FILE, tokens);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// Data Management
|
||||
static async saveUserData(userId: string, data: any): Promise<void> {
|
||||
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
|
||||
const releaseLock = await FileLock.acquire(`user-${userId}`);
|
||||
|
||||
try {
|
||||
// Serialize dates
|
||||
const serialized = {
|
||||
...data,
|
||||
transactions: (data.transactions || []).map((t: any) => ({
|
||||
...t,
|
||||
date: t.date instanceof Date ? t.date.toISOString() : t.date
|
||||
}))
|
||||
date: t.date instanceof Date ? t.date.toISOString() : t.date,
|
||||
})),
|
||||
};
|
||||
|
||||
const encryptedData = await encryptData(JSON.stringify(serialized), ENCRYPTION_KEY);
|
||||
const fileData = {
|
||||
data: encryptedData,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fsPromises.writeFile(userDataFile, JSON.stringify(fileData, null, 2), 'utf-8');
|
||||
await writeJson(userDataFile, { data: encryptedData, lastUpdated: new Date().toISOString() });
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
@@ -169,73 +225,61 @@ export class FileStorageService {
|
||||
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
|
||||
|
||||
try {
|
||||
const fileData = JSON.parse(await fsPromises.readFile(userDataFile, 'utf-8'));
|
||||
const fileData = await readJson<{ data: string }>(userDataFile);
|
||||
const decryptedData = await decryptData(fileData.data, ENCRYPTION_KEY);
|
||||
const parsed = JSON.parse(decryptedData);
|
||||
|
||||
// Ensure consistent date handling - always return ISO strings
|
||||
return {
|
||||
...parsed,
|
||||
transactions: (parsed.transactions || []).map((t: any) => ({
|
||||
...t,
|
||||
date: typeof t.date === 'string' ? t.date : new Date(t.date).toISOString()
|
||||
}))
|
||||
date: typeof t.date === 'string' ? t.date : new Date(t.date).toISOString(),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
// Return default data for new users or on read error
|
||||
if ((error as any).code === 'ENOENT') {
|
||||
return {
|
||||
accounts: [],
|
||||
envelopes: [],
|
||||
transactions: [],
|
||||
setupCompleted: false,
|
||||
};
|
||||
return { accounts: [], envelopes: [], transactions: [], setupCompleted: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteUser(userId: string): Promise<void> {
|
||||
static async deleteUser(userId: string, sessionToken?: string): Promise<void> {
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
|
||||
try {
|
||||
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
|
||||
|
||||
try {
|
||||
await fsPromises.unlink(userDataFile);
|
||||
} catch (error) {
|
||||
// File might not exist, that's ok
|
||||
if ((error as any).code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
// Also remove from users.json
|
||||
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
|
||||
const users: Users = JSON.parse(content);
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const userEmail = Object.keys(users).find(email => users[email].userId === userId);
|
||||
if (userEmail) {
|
||||
delete users[userEmail];
|
||||
await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
|
||||
await writeJson(USERS_FILE, users);
|
||||
}
|
||||
|
||||
if (sessionToken) await this.deleteSession(sessionToken);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
static async resetPassword(email: string, newPassword: string): Promise<void> {
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
|
||||
private static async _createSession(userId: string, email: string): Promise<string> {
|
||||
const releaseLock = await FileLock.acquire('sessions-file');
|
||||
try {
|
||||
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
|
||||
const users: Users = JSON.parse(content);
|
||||
|
||||
if (!users[email]) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
users[email].passwordHash = passwordHash;
|
||||
|
||||
await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
|
||||
const sessions = await readJson<Sessions>(SESSIONS_FILE);
|
||||
const token = crypto.randomUUID() + '-' + crypto.randomUUID();
|
||||
sessions[token] = {
|
||||
userId,
|
||||
email,
|
||||
expiresAt: new Date(Date.now() + SESSION_TTL_MS).toISOString(),
|
||||
};
|
||||
await writeJson(SESSIONS_FILE, sessions);
|
||||
return token;
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user