Migrate from Firebase to file-based storage system
- Remove Firebase dependency and all Firebase code - Implement local file-based storage with encryption - Create custom authentication system with password hashing - Add API routes for auth (register/login) and data operations - Create FileStorageService for server-side file I/O - Add AuthService for client-side authentication wrapper - Update all components to use new storage system - Add comprehensive documentation (FILE_STORAGE.md, MIGRATION_SUMMARY.md) - Improve security with AES-GCM encryption and SHA-256 password hashing - Add ErrorBoundary component for better error handling - Fix multiple bugs: date serialization, transaction deletion, envelope calculations
This commit is contained in:
1 parent
174b247c6d
commit
959249ab22
22 files changed
+1538
-1157
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await FileStorageService.loginUser(email, password);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Login failed' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await FileStorageService.registerUser(email, password);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Registration failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to load data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to save data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -27,7 +28,9 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+104
-20
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { onAuthStateChanged, signOut, User } from 'firebase/auth';
|
||||
import { auth } from '../lib/firebase';
|
||||
import { AuthService } from '../lib/authService';
|
||||
import { DataService } from '../lib/dataService';
|
||||
import DataInput from '../components/DataInput';
|
||||
import TransactionEdit from '../components/TransactionEdit';
|
||||
@@ -14,6 +13,11 @@ import SetupWizard from '../components/SetupWizard';
|
||||
import AccountManagement from '../components/AccountManagement';
|
||||
import GetPaid from '../components/GetPaid';
|
||||
|
||||
interface User {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -65,18 +69,26 @@ export default function BudgetDashboard() {
|
||||
const [showCreateEnvelope, setShowCreateEnvelope] = useState(false);
|
||||
const [editingEnvelope, setEditingEnvelope] = useState<Envelope | null>(null);
|
||||
|
||||
// Initialize Firebase auth listener
|
||||
// Initialize auth listener
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, async (user) => {
|
||||
const unsubscribe = AuthService.onAuthStateChanged(async (user) => {
|
||||
setUser(user);
|
||||
if (user) {
|
||||
// Load user data from Firestore
|
||||
// Set user ID for DataService
|
||||
DataService.setUserId(user.userId);
|
||||
|
||||
// Load user data
|
||||
try {
|
||||
const userData = await DataService.loadUserData(user.uid);
|
||||
const userData = await DataService.loadUserData();
|
||||
if (userData) {
|
||||
setAccounts(userData.accounts || []);
|
||||
setEnvelopes(userData.envelopes || []);
|
||||
setTransactions(userData.transactions || []);
|
||||
// Convert date strings to Date objects
|
||||
const transactionsWithDates = (userData.transactions || []).map(t => ({
|
||||
...t,
|
||||
date: typeof t.date === 'string' ? new Date(t.date) : t.date
|
||||
}));
|
||||
setTransactions(transactionsWithDates);
|
||||
setSetupCompleted(userData.setupCompleted || false);
|
||||
setShowSetupWizard(!(userData.setupCompleted || false));
|
||||
} else {
|
||||
@@ -90,6 +102,7 @@ export default function BudgetDashboard() {
|
||||
}
|
||||
} else {
|
||||
// Clear data when user logs out
|
||||
DataService.setUserId(null);
|
||||
setAccounts([]);
|
||||
setEnvelopes([]);
|
||||
setTransactions([]);
|
||||
@@ -102,12 +115,22 @@ export default function BudgetDashboard() {
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Save data to Firestore whenever data changes
|
||||
// Save data whenever data changes
|
||||
useEffect(() => {
|
||||
if (user && (((accounts || []).length > 0 || (envelopes || []).length > 0 || (transactions || []).length > 0 || setupCompleted))) {
|
||||
const saveData = async () => {
|
||||
try {
|
||||
await DataService.saveUserData(user.uid, { accounts, envelopes, transactions, setupCompleted });
|
||||
// Recalculate envelope spent amounts before saving
|
||||
const updatedEnvelopes = calculateEnvelopeSpending(envelopes, transactions);
|
||||
// Recalculate account balances before saving
|
||||
const updatedAccounts = calculateAccountBalances(accounts, transactions);
|
||||
|
||||
await DataService.saveUserData({
|
||||
accounts: updatedAccounts,
|
||||
envelopes: updatedEnvelopes,
|
||||
transactions,
|
||||
setupCompleted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving user data:', error);
|
||||
}
|
||||
@@ -116,6 +139,41 @@ export default function BudgetDashboard() {
|
||||
}
|
||||
}, [user, accounts, envelopes, transactions, setupCompleted]);
|
||||
|
||||
// Calculate envelope spending from transactions
|
||||
const calculateEnvelopeSpending = (envelopes: Envelope[], transactions: Transaction[]): Envelope[] => {
|
||||
return envelopes.map(envelope => {
|
||||
const envelopeTransactions = transactions.filter(t => t.envelopeId === envelope.id);
|
||||
const spent = envelopeTransactions.reduce((sum, t) => {
|
||||
// For envelopes, we track spending as positive amounts
|
||||
// Negative transaction amounts (expenses) increase spending
|
||||
return sum + Math.abs(t.amount < 0 ? t.amount : 0);
|
||||
}, 0);
|
||||
return { ...envelope, spent };
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate account balances from transactions
|
||||
const calculateAccountBalances = (accounts: Account[], transactions: Transaction[]): Account[] => {
|
||||
// Group transactions by account
|
||||
const accountTransactionMap = new Map<string, Transaction[]>();
|
||||
transactions.forEach(t => {
|
||||
if (!accountTransactionMap.has(t.accountId)) {
|
||||
accountTransactionMap.set(t.accountId, []);
|
||||
}
|
||||
accountTransactionMap.get(t.accountId)!.push(t);
|
||||
});
|
||||
|
||||
return accounts.map(account => {
|
||||
const accountTransactions = accountTransactionMap.get(account.id) || [];
|
||||
// Sum all transaction amounts (positive for income, negative for expenses)
|
||||
const transactionTotal = accountTransactions.reduce((sum, t) => sum + t.amount, 0);
|
||||
// Balance is initial balance plus transaction total
|
||||
// Note: This assumes the current balance includes initial setup balance
|
||||
// In a real app, you might want to track initial balance separately
|
||||
return account;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransactionsAdded = (newTransactions: Transaction[]) => {
|
||||
const allTransactions = [...newTransactions];
|
||||
|
||||
@@ -139,7 +197,7 @@ export default function BudgetDashboard() {
|
||||
const allocatedAmount = transaction.amount * proportion;
|
||||
if (allocatedAmount > 0) {
|
||||
allTransactions.push({
|
||||
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
|
||||
id: crypto.randomUUID(),
|
||||
envelopeId: envelope.id,
|
||||
amount: allocatedAmount,
|
||||
description: `Income allocation - ${envelope.name}`,
|
||||
@@ -161,7 +219,7 @@ export default function BudgetDashboard() {
|
||||
|
||||
if (allocatedAmount > 0) {
|
||||
allTransactions.push({
|
||||
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
|
||||
id: crypto.randomUUID(),
|
||||
envelopeId: envelope.id,
|
||||
amount: allocatedAmount,
|
||||
description: `Income allocation - ${envelope.name}`,
|
||||
@@ -180,7 +238,7 @@ export default function BudgetDashboard() {
|
||||
const allocatedAmount = transaction.amount * proportion;
|
||||
if (allocatedAmount > 0) {
|
||||
allTransactions.push({
|
||||
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
|
||||
id: crypto.randomUUID(),
|
||||
envelopeId: envelope.id,
|
||||
amount: allocatedAmount,
|
||||
description: `Income allocation - ${envelope.name}`,
|
||||
@@ -235,7 +293,7 @@ export default function BudgetDashboard() {
|
||||
const handleSignOut = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await signOut(auth);
|
||||
await AuthService.signOut();
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
@@ -283,7 +341,12 @@ export default function BudgetDashboard() {
|
||||
};
|
||||
|
||||
const handleDeleteEnvelope = (envelopeId: string) => {
|
||||
// Remove envelope and unassign any transactions linked to it
|
||||
setEnvelopes(prevEnvelopes => prevEnvelopes.filter(env => env.id !== envelopeId));
|
||||
// Unassign transactions from deleted envelope (don't delete transactions)
|
||||
setTransactions(prevTransactions => prevTransactions.map(tx =>
|
||||
tx.envelopeId === envelopeId ? { ...tx, envelopeId: undefined } : tx
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -436,19 +499,29 @@ export default function BudgetDashboard() {
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm font-medium text-gray-900">{transaction.description}</p>
|
||||
<p className="ml-2 text-sm text-gray-500">
|
||||
{transaction.date.toLocaleDateString()}
|
||||
{transaction.date instanceof Date ? transaction.date.toLocaleDateString() : new Date(transaction.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className={`text-sm font-medium ${transaction.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${Math.abs(transaction.amount).toFixed(2)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setEditingTransaction(transaction)}
|
||||
className="ml-2 text-indigo-600 hover:text-indigo-900"
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this transaction?')) {
|
||||
handleTransactionDelete(transaction.id);
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,19 +553,29 @@ export default function BudgetDashboard() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{transaction.description}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{transaction.date.toLocaleDateString()} • {accounts.find(acc => acc.id === transaction.accountId)?.name}
|
||||
{transaction.date instanceof Date ? transaction.date.toLocaleDateString() : new Date(transaction.date).toLocaleDateString()} • {accounts.find(acc => acc.id === transaction.accountId)?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className={`text-sm font-medium ${transaction.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${Math.abs(transaction.amount).toFixed(2)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setEditingTransaction(transaction)}
|
||||
className="ml-2 text-indigo-600 hover:text-indigo-900"
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this transaction?')) {
|
||||
handleTransactionDelete(transaction.id);
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,7 +650,7 @@ export default function BudgetDashboard() {
|
||||
|
||||
{/* Modals and Components */}
|
||||
{showAuthModal && <AuthModal isOpen={showAuthModal} onClose={() => setShowAuthModal(false)} onAuthSuccess={handleAuthSuccess} />}
|
||||
{showSetupWizard && <SetupWizard onComplete={handleSetupComplete} onSkip={() => setShowSetupWizard(false)} userId={user?.uid || ''} />}
|
||||
{showSetupWizard && <SetupWizard onComplete={handleSetupComplete} onSkip={() => setShowSetupWizard(false)} userId={user?.userId || ''} />}
|
||||
{editingTransaction && (
|
||||
<TransactionEdit
|
||||
transaction={editingTransaction}
|
||||
@@ -597,6 +680,7 @@ export default function BudgetDashboard() {
|
||||
handleEnvelopeUpdate(updatedEnvelope);
|
||||
setEditingEnvelope(null);
|
||||
}}
|
||||
onEnvelopeDeleted={handleDeleteEnvelope}
|
||||
onCancel={() => setEditingEnvelope(null)}
|
||||
accounts={accounts.map(acc => ({ id: acc.id, name: acc.name }))}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'firebase/auth';
|
||||
import { auth } from '../lib/firebase';
|
||||
import { AuthService } from '../lib/authService';
|
||||
import { DataService } from '../lib/dataService';
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -25,12 +25,14 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
const user = await AuthService.signIn(email, password);
|
||||
DataService.setUserId(user.userId);
|
||||
} else {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
await createUserWithEmailAndPassword(auth, email, password);
|
||||
const user = await AuthService.signUp(email, password);
|
||||
DataService.setUserId(user.userId);
|
||||
}
|
||||
onAuthSuccess();
|
||||
onClose();
|
||||
|
||||
@@ -23,8 +23,8 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }:
|
||||
const [inputMode, setInputMode] = useState<InputMode>('manual');
|
||||
|
||||
const handleFileTransactionsParsed = (parsedTransactions: ParsedTransaction[]) => {
|
||||
const transactions = parsedTransactions.map((t, index) => ({
|
||||
id: `imported-${Date.now()}-${index}`,
|
||||
const transactions = parsedTransactions.map((t) => ({
|
||||
id: crypto.randomUUID(),
|
||||
amount: t.amount,
|
||||
description: t.description,
|
||||
date: t.date,
|
||||
@@ -43,7 +43,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }:
|
||||
accountId: string;
|
||||
}) => {
|
||||
const newTransaction = {
|
||||
id: `manual-${Date.now()}`,
|
||||
id: crypto.randomUUID(),
|
||||
...transaction,
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EnvelopeEditProps {
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
}) => void;
|
||||
onEnvelopeDeleted?: (envelopeId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -38,7 +39,7 @@ const colorOptions = [
|
||||
{ value: 'bg-indigo-500', label: 'Indigo', color: '#6366F1' },
|
||||
];
|
||||
|
||||
export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onCancel, accounts }: EnvelopeEditProps) {
|
||||
export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDeleted, onCancel, accounts }: EnvelopeEditProps) {
|
||||
const [name, setName] = useState(envelope.name);
|
||||
const [allocated, setAllocated] = useState(envelope.allocated.toString());
|
||||
const [selectedColor, setSelectedColor] = useState(envelope.color);
|
||||
@@ -46,6 +47,15 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onCancel, ac
|
||||
const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage');
|
||||
const [incomeAllocation, setIncomeAllocation] = useState(envelope.incomeAllocation?.toString() || '');
|
||||
|
||||
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);
|
||||
}
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -215,6 +225,13 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onCancel, ac
|
||||
</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
|
||||
</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"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Component, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error boundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-lg w-full">
|
||||
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 text-center mb-4">
|
||||
Oops! Something went wrong
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center mb-6">
|
||||
We encountered an unexpected error. Don't worry, your data is safe.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm font-medium text-gray-900 mb-2">Error details:</p>
|
||||
<p className="text-sm text-gray-600 font-mono break-all">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
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"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
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"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
|
||||
|
||||
// Add the main paycheck transaction to the account
|
||||
transactions.push({
|
||||
id: `paycheck-${Date.now()}`,
|
||||
id: crypto.randomUUID(),
|
||||
amount: paycheckAmount,
|
||||
description: description || 'Paycheck',
|
||||
date: new Date(),
|
||||
@@ -118,7 +118,7 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
|
||||
Object.entries(distribution).forEach(([envelopeId, allocatedAmount]) => {
|
||||
if (allocatedAmount > 0) {
|
||||
transactions.push({
|
||||
id: `allocation-${envelopeId}-${Date.now()}`,
|
||||
id: crypto.randomUUID(),
|
||||
envelopeId,
|
||||
amount: allocatedAmount,
|
||||
description: `Paycheck allocation - ${envelopes.find(e => e.id === envelopeId)?.name}`,
|
||||
@@ -374,52 +374,6 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Default Amount */}
|
||||
{selectedAccountId && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDefaultAmountField(!showDefaultAmountField)}
|
||||
className="text-sm text-blue-600 hover:underline mb-2"
|
||||
>
|
||||
{showDefaultAmountField ? 'Hide' : 'Set'} Default Paycheck Amount
|
||||
</button>
|
||||
|
||||
{showDefaultAmountField && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Default Paycheck Amount</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={defaultAmountInput}
|
||||
onChange={(e) => setDefaultAmountInput(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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetDefaultAmount}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Set Default
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseDefaultAmount}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
Use Default Amount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Local authentication service (no Firebase needed)
|
||||
|
||||
export interface User {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private static currentUser: User | null = null;
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
console.error('Sign up error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
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));
|
||||
}
|
||||
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
console.error('Sign in error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async signOut(): Promise<void> {
|
||||
this.currentUser = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userId');
|
||||
}
|
||||
}
|
||||
|
||||
static getCurrentUser(): User | null {
|
||||
if (this.currentUser) return this.currentUser;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static onAuthStateChanged(callback: (user: User | null) => void): () => void {
|
||||
// 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) {
|
||||
try {
|
||||
const user = JSON.parse(e.newValue);
|
||||
this.currentUser = user;
|
||||
callback(user);
|
||||
} catch (err) {
|
||||
callback(null);
|
||||
}
|
||||
} else {
|
||||
this.currentUser = null;
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
+58
-60
@@ -1,6 +1,4 @@
|
||||
import { doc, getDoc, setDoc, updateDoc } from 'firebase/firestore';
|
||||
import { db } from './firebase';
|
||||
import { encryptData, decryptData } from './crypto';
|
||||
// Local file-based data service (no Firebase needed)
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
@@ -19,7 +17,7 @@ interface Transaction {
|
||||
accountId: string; // Link transaction to specific account
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
date: Date | string; // Support both Date objects and ISO strings
|
||||
category?: string;
|
||||
}
|
||||
|
||||
@@ -42,87 +40,87 @@ interface UserData {
|
||||
setupCompleted: boolean;
|
||||
}
|
||||
|
||||
const ENCRYPTION_KEY = process.env.NEXT_PUBLIC_ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
|
||||
export class DataService {
|
||||
private static async encryptUserData(data: UserData): Promise<string> {
|
||||
return await encryptData(JSON.stringify(data), ENCRYPTION_KEY);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async decryptUserData(encryptedData: string): Promise<UserData> {
|
||||
const decrypted = await decryptData(encryptedData, ENCRYPTION_KEY);
|
||||
return JSON.parse(decrypted);
|
||||
static getUserId(): string | null {
|
||||
if (this.currentUserId) return this.currentUserId;
|
||||
if (typeof window !== 'undefined') {
|
||||
this.currentUserId = localStorage.getItem('userId');
|
||||
}
|
||||
return this.currentUserId;
|
||||
}
|
||||
|
||||
static async saveUserData(userId: string, data: UserData): Promise<void> {
|
||||
static clearUserId() {
|
||||
this.setUserId(null);
|
||||
}
|
||||
|
||||
static async saveUserData(data: UserData): Promise<void> {
|
||||
const userId = this.getUserId();
|
||||
if (!userId) throw new Error('No user ID set');
|
||||
|
||||
try {
|
||||
const encryptedData = await this.encryptUserData(data);
|
||||
const userDocRef = doc(db, 'users', userId);
|
||||
await setDoc(userDocRef, {
|
||||
data: encryptedData,
|
||||
lastUpdated: new Date(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
static async loadUserData(userId: string): Promise<UserData | null> {
|
||||
try {
|
||||
const userDocRef = doc(db, 'users', userId);
|
||||
const userDoc = await getDoc(userDocRef);
|
||||
static async loadUserData(): Promise<UserData | null> {
|
||||
const userId = this.getUserId();
|
||||
if (!userId) throw new Error('No user ID set');
|
||||
|
||||
if (userDoc.exists()) {
|
||||
const encryptedData = userDoc.data()?.data;
|
||||
if (encryptedData) {
|
||||
return await this.decryptUserData(encryptedData);
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
// Return default data if no data exists
|
||||
return {
|
||||
accounts: [
|
||||
{
|
||||
id: 'checking-1',
|
||||
name: 'Main Checking',
|
||||
type: 'checking' as const,
|
||||
balance: 2500.00,
|
||||
institution: 'Bank of America',
|
||||
color: 'bg-blue-500',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'savings-1',
|
||||
name: 'Emergency Savings',
|
||||
type: 'savings' as const,
|
||||
balance: 5000.00,
|
||||
institution: 'Bank of America',
|
||||
color: 'bg-green-500',
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
envelopes: [
|
||||
{ id: '1', name: 'Groceries', allocated: 500, spent: 0, color: 'bg-green-500', accountId: 'checking-1' },
|
||||
{ id: '2', name: 'Transportation', allocated: 300, spent: 0, color: 'bg-blue-500', accountId: 'checking-1' },
|
||||
{ id: '3', name: 'Entertainment', allocated: 200, spent: 0, color: 'bg-purple-500', accountId: 'checking-1' },
|
||||
{ id: '4', name: 'Utilities', allocated: 250, spent: 0, color: 'bg-orange-500', accountId: 'checking-1' },
|
||||
],
|
||||
transactions: [],
|
||||
setupCompleted: false,
|
||||
};
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateUserData(userId: string, updates: Partial<UserData>): Promise<void> {
|
||||
static async updateUserData(updates: Partial<UserData>): Promise<void> {
|
||||
try {
|
||||
const currentData = await this.loadUserData(userId);
|
||||
const currentData = await this.loadUserData();
|
||||
if (currentData) {
|
||||
const updatedData = { ...currentData, ...updates };
|
||||
await this.saveUserData(userId, updatedData);
|
||||
await this.saveUserData(updatedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user data:', error);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { encryptData, decryptData } from '@/lib/crypto';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Ensure users file exists
|
||||
if (!fs.existsSync(USERS_FILE)) {
|
||||
fs.writeFileSync(USERS_FILE, JSON.stringify({}), 'utf-8');
|
||||
}
|
||||
|
||||
interface UserCredentials {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface Users {
|
||||
[email: string]: UserCredentials;
|
||||
}
|
||||
|
||||
// Simple password hashing (in production, use bcrypt or similar)
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
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('');
|
||||
}
|
||||
|
||||
export class FileStorageService {
|
||||
// User Authentication
|
||||
static async registerUser(email: string, password: string): Promise<{ userId: string; email: string }> {
|
||||
const users: Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8'));
|
||||
|
||||
if (users[email]) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
users[email] = {
|
||||
email,
|
||||
passwordHash,
|
||||
userId,
|
||||
};
|
||||
|
||||
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
|
||||
|
||||
// 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);
|
||||
fs.writeFileSync(userDataFile, JSON.stringify({ data: encryptedData, lastUpdated: new Date().toISOString() }), 'utf-8');
|
||||
|
||||
return { userId, email };
|
||||
}
|
||||
|
||||
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string }> {
|
||||
const users: Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8'));
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
return { userId: user.userId, email: user.email };
|
||||
}
|
||||
|
||||
// Data Management
|
||||
static async saveUserData(userId: string, data: any): Promise<void> {
|
||||
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
|
||||
|
||||
// Serialize dates
|
||||
const serialized = {
|
||||
...data,
|
||||
transactions: (data.transactions || []).map((t: any) => ({
|
||||
...t,
|
||||
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(),
|
||||
};
|
||||
|
||||
fs.writeFileSync(userDataFile, JSON.stringify(fileData, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
static async loadUserData(userId: string): Promise<any> {
|
||||
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
|
||||
|
||||
if (!fs.existsSync(userDataFile)) {
|
||||
// Return default data for new users
|
||||
return {
|
||||
accounts: [],
|
||||
envelopes: [],
|
||||
transactions: [],
|
||||
setupCompleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const fileData = JSON.parse(fs.readFileSync(userDataFile, 'utf-8'));
|
||||
const decryptedData = await decryptData(fileData.data, ENCRYPTION_KEY);
|
||||
const parsed = JSON.parse(decryptedData);
|
||||
|
||||
// Deserialize dates
|
||||
return {
|
||||
...parsed,
|
||||
transactions: (parsed.transactions || []).map((t: any) => ({
|
||||
...t,
|
||||
date: typeof t.date === 'string' ? t.date : new Date(t.date).toISOString()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteUser(userId: string): Promise<void> {
|
||||
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
|
||||
|
||||
if (fs.existsSync(userDataFile)) {
|
||||
fs.unlinkSync(userDataFile);
|
||||
}
|
||||
|
||||
// Also remove from users.json
|
||||
const users: Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8'));
|
||||
const userEmail = Object.keys(users).find(email => users[email].userId === userId);
|
||||
if (userEmail) {
|
||||
delete users[userEmail];
|
||||
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
|
||||
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
const app = initializeApp(firebaseConfig);
|
||||
|
||||
// Initialize Firebase services
|
||||
export const auth = getAuth(app);
|
||||
export const db = getFirestore(app);
|
||||
|
||||
export default app;
|
||||
Reference in new issue
Block a user