Fix envelope updates, balance persistence, Get Paid duplication, form readability, and income allocation carryover

This commit is contained in:
nmemmert committed 2026-01-13 13:38:27 -05:00
1 parent caa6020ae8
commit 1b269dedd9
24 files changed
+2265 -472

No files matched your search

+30 -2
View File
@@ -7,6 +7,7 @@ export interface User {
export class AuthService {
private static currentUser: User | null = null;
private static authCallbacks: Set<(user: User | null) => void> = new Set();
static async signUp(email: string, password: string): Promise<User> {
try {
@@ -31,6 +32,9 @@ export class AuthService {
localStorage.setItem('user', JSON.stringify(result.user));
}
// Notify all listeners
this.notifyAuthChange(result.user);
return result.user;
} catch (error) {
// Only log non-authentication errors to avoid console spam
@@ -65,6 +69,9 @@ export class AuthService {
localStorage.setItem('user', JSON.stringify(result.user));
}
// Notify all listeners
this.notifyAuthChange(result.user);
return result.user;
} catch (error) {
// Only log non-authentication errors to avoid console spam
@@ -81,6 +88,9 @@ export class AuthService {
localStorage.removeItem('user');
localStorage.removeItem('userId');
}
// Notify all listeners
this.notifyAuthChange(null);
}
static getCurrentUser(): User | null {
@@ -102,6 +112,9 @@ export class AuthService {
}
static onAuthStateChanged(callback: (user: User | null) => void): () => void {
// Add callback to listeners
this.authCallbacks.add(callback);
// Initial call with current user
const user = this.getCurrentUser();
callback(user);
@@ -126,9 +139,24 @@ export class AuthService {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
this.authCallbacks.delete(callback);
};
}
return () => {};
return () => {
this.authCallbacks.delete(callback);
};
}
private static notifyAuthChange(user: User | null): void {
this.authCallbacks.forEach(callback => {
try {
callback(user);
} catch (err) {
console.error('Error in auth callback:', err);
}
});
}
}
+1
View File
@@ -26,6 +26,7 @@ interface Account {
name: string;
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
balance: number;
startingBalance?: number;
institution?: string;
accountNumber?: string;
color: string;
+173 -84
View File
@@ -1,21 +1,33 @@
import fs from 'fs';
import path from 'path';
import { promises as fsPromises } from 'fs';
import { createHash } from 'crypto';
import { encryptData, decryptData } from '@/lib/crypto';
const DATA_DIR = path.join(process.cwd(), 'data');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const LOCK_DIR = path.join(DATA_DIR, 'locks');
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 });
// Initialize directories
async function initializeDirectories() {
try {
await fsPromises.mkdir(DATA_DIR, { recursive: true });
await fsPromises.mkdir(LOCK_DIR, { recursive: true });
// Ensure users file exists
try {
await fsPromises.access(USERS_FILE);
} catch {
await fsPromises.writeFile(USERS_FILE, JSON.stringify({}), 'utf-8');
}
} catch (error) {
console.error('Error initializing directories:', error);
}
}
// Ensure users file exists
if (!fs.existsSync(USERS_FILE)) {
fs.writeFileSync(USERS_FILE, JSON.stringify({}), 'utf-8');
}
initializeDirectories();
interface UserCredentials {
email: string;
@@ -27,52 +39,91 @@ interface Users {
[email: string]: UserCredentials;
}
// Lock mechanism for concurrent access prevention
class FileLock {
private static locks = new Map<string, Promise<void>>();
static async acquire(key: string): Promise<() => void> {
// Wait for any existing lock
const existingLock = this.locks.get(key);
if (existingLock) {
await existingLock;
}
let releaseLock: () => void;
const lockPromise = new Promise<void>((resolve) => {
releaseLock = () => {
this.locks.delete(key);
resolve();
};
});
this.locks.set(key, lockPromise);
return releaseLock!;
}
}
// 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('');
// Prefer Web Crypto when available (browser/modern Node), otherwise fall back to Node crypto
if (typeof crypto !== 'undefined' && crypto.subtle) {
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('');
}
// Fallback for environments without crypto.subtle
return createHash('sha256').update(password, 'utf8').digest('hex');
}
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'));
const releaseLock = await FileLock.acquire('users-file');
if (users[email]) {
throw new Error('User already exists');
try {
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
if (users[email]) {
throw new Error('User already exists');
}
const userId = crypto.randomUUID();
const passwordHash = await hashPassword(password);
users[email] = {
email,
passwordHash,
userId,
};
await fsPromises.writeFile(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);
await fsPromises.writeFile(userDataFile, JSON.stringify({ data: encryptedData, lastUpdated: new Date().toISOString() }), 'utf-8');
return { userId, email };
} finally {
releaseLock();
}
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 content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
const user = users[email];
if (!user) {
@@ -90,65 +141,103 @@ export class FileStorageService {
// Data Management
static async saveUserData(userId: string, data: any): Promise<void> {
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
const releaseLock = await FileLock.acquire(`user-${userId}`);
// Serialize dates
const serialized = {
...data,
transactions: (data.transactions || []).map((t: any) => ({
...t,
date: t.date instanceof Date ? t.date.toISOString() : t.date
}))
};
try {
// 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(),
};
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');
await fsPromises.writeFile(userDataFile, JSON.stringify(fileData, null, 2), 'utf-8');
} finally {
releaseLock();
}
}
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
try {
const fileData = JSON.parse(await fsPromises.readFile(userDataFile, 'utf-8'));
const decryptedData = await decryptData(fileData.data, ENCRYPTION_KEY);
const parsed = JSON.parse(decryptedData);
// Ensure consistent date handling - always return ISO strings
return {
accounts: [],
envelopes: [],
transactions: [],
setupCompleted: false,
...parsed,
transactions: (parsed.transactions || []).map((t: any) => ({
...t,
date: typeof t.date === 'string' ? t.date : new Date(t.date).toISOString()
}))
};
} catch (error) {
// Return default data for new users or on read error
if ((error as any).code === 'ENOENT') {
return {
accounts: [],
envelopes: [],
transactions: [],
setupCompleted: false,
};
}
throw error;
}
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`);
const releaseLock = await FileLock.acquire('users-file');
if (fs.existsSync(userDataFile)) {
fs.unlinkSync(userDataFile);
}
try {
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
try {
await fsPromises.unlink(userDataFile);
} catch (error) {
// File might not exist, that's ok
if ((error as any).code !== 'ENOENT') throw error;
}
// 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');
// Also remove from users.json
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
const userEmail = Object.keys(users).find(email => users[email].userId === userId);
if (userEmail) {
delete users[userEmail];
await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
}
} finally {
releaseLock();
}
}
static async resetPassword(email: string, newPassword: string): Promise<void> {
const releaseLock = await FileLock.acquire('users-file');
try {
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
if (!users[email]) {
throw new Error('User not found');
}
const passwordHash = await hashPassword(newPassword);
users[email].passwordHash = passwordHash;
await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
} finally {
releaseLock();
}
}
}