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

+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;