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,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