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:
Nate Emmert committed 2025-11-08 14:03:26 -05:00
1 parent 174b247c6d
commit 959249ab22
22 files changed
+1538 -1157

No files matched your search

+27
View File
@@ -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 }
);
}
}
+27
View File
@@ -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 }
);
}
}
+52
View File
@@ -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
View File
@@ -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
View File
@@ -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 }))}
/>
+6 -4
View File
@@ -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();
+3 -3
View File
@@ -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,
};
+18 -1
View File
@@ -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"
+82
View File
@@ -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;
}
}
+2 -48
View File
@@ -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
+127
View File
@@ -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
View File
@@ -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);
+154
View File
@@ -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');
}
}
}
-21
View File
@@ -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;