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

+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