Major feature release: security hardening + Goals, Rules, search, charts

Security:
- Replace SHA-256 password hashing with bcrypt (cost 12) + timing-safe compare
- Introduce server-side session tokens (x-session-token header); drop x-user-id
- Add /api/auth/logout endpoint to invalidate sessions
- Enforce 8-char minimum password server-side
- Fix password reset: now uses real signed tokens with 1-hour TTL

Features:
- Goals: savings goals with progress bars, target dates, add/withdraw funds
- Auto-categorization Rules: keyword → envelope mapping applied on transaction add
- Transaction search/filter: description, account, envelope, date range
- Transaction table: shows envelope color dot, running balance column
- Monthly Spending History chart (last 6 months income vs expenses bar chart)
- Spending Insights: month-over-month comparison + overspent envelope callouts
- Budget progress expanded: per-envelope bars with rollover indicator
- Envelope rollover toggle: unspent funds can carry forward
- Recurring transactions auto-applied on login
- Save indicator in nav (Saving… / Saved ✓ / Save failed)
- Nav overflow fixed: all tabs scroll horizontally instead of clipping
- PWA manifest added for home-screen install support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 08:22:24 -04:00
1 parent 8814423fa6
commit 20ea25f4de
19 files changed
+1685 -1486

No files matched your search

+72 -102
View File
@@ -1,4 +1,4 @@
// Local authentication service (no Firebase needed)
'use client';
export interface User {
userId: string;
@@ -7,119 +7,80 @@ export interface User {
export class AuthService {
private static currentUser: User | null = null;
private static sessionToken: string | null = null;
private static authCallbacks: Set<(user: User | null) => void> = new Set();
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 }),
});
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));
}
// Notify all listeners
this.notifyAuthChange(result.user);
return result.user;
} catch (error) {
// Only log non-authentication errors to avoid console spam
if (!(error instanceof Error) || !error.message.includes('User already exists')) {
console.error('Sign up error:', error);
}
throw error;
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Registration failed');
}
const result = await response.json();
this._persist(result.user, result.sessionToken);
this.notifyAuthChange(result.user);
return result.user;
}
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 }),
});
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();
// Don't log authentication errors to console as they may contain sensitive info
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));
}
// Notify all listeners
this.notifyAuthChange(result.user);
return result.user;
} catch (error) {
// Only log non-authentication errors to avoid console spam
if (!(error instanceof Error) || !error.message.includes('Invalid credentials')) {
console.error('Sign in error:', error);
}
throw error;
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
const result = await response.json();
this._persist(result.user, result.sessionToken);
this.notifyAuthChange(result.user);
return result.user;
}
static async signOut(): Promise<void> {
this.currentUser = null;
if (typeof window !== 'undefined') {
localStorage.removeItem('user');
localStorage.removeItem('userId');
const token = this.getSessionToken();
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'x-session-token': token },
}).catch(() => {});
}
// Notify all listeners
this._clear();
this.notifyAuthChange(null);
}
static getCurrentUser(): User | null {
if (this.currentUser) return this.currentUser;
if (typeof window !== 'undefined') {
if (typeof window === 'undefined') return null;
try {
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;
if (userJson) this.currentUser = JSON.parse(userJson);
} catch {}
return this.currentUser;
}
static getSessionToken(): string | null {
if (this.sessionToken) return this.sessionToken;
if (typeof window === 'undefined') return null;
this.sessionToken = localStorage.getItem('sessionToken');
return this.sessionToken;
}
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);
callback(this.getCurrentUser());
// Set up listener for storage events (for multi-tab support)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'user') {
if (e.newValue) {
@@ -127,9 +88,7 @@ export class AuthService {
const user = JSON.parse(e.newValue);
this.currentUser = user;
callback(user);
} catch (err) {
callback(null);
}
} catch { callback(null); }
} else {
this.currentUser = null;
callback(null);
@@ -145,18 +104,29 @@ export class AuthService {
};
}
return () => {
this.authCallbacks.delete(callback);
};
return () => { this.authCallbacks.delete(callback); };
}
private static _persist(user: User, token: string): void {
this.currentUser = user;
this.sessionToken = token;
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('sessionToken', token);
}
}
private static _clear(): void {
this.currentUser = null;
this.sessionToken = null;
if (typeof window !== 'undefined') {
localStorage.removeItem('user');
localStorage.removeItem('sessionToken');
localStorage.removeItem('userId');
}
}
private static notifyAuthChange(user: User | null): void {
this.authCallbacks.forEach(callback => {
try {
callback(user);
} catch (err) {
console.error('Error in auth callback:', err);
}
});
this.authCallbacks.forEach(cb => { try { cb(user); } catch {} });
}
}
+39 -81
View File
@@ -1,4 +1,4 @@
// Local file-based data service (no Firebase needed)
import { AuthService } from './authService';
interface Envelope {
id: string;
@@ -6,18 +6,18 @@ interface Envelope {
allocated: number;
spent: number;
color: string;
accountId: string; // Link envelope to specific account
incomeAllocation?: number; // Percentage (0-100) or fixed amount if negative
incomeAllocationType?: 'percentage' | 'fixed'; // How to interpret incomeAllocation
accountId: string;
incomeAllocation?: number;
incomeAllocationType?: 'percentage' | 'fixed';
}
interface Transaction {
id: string;
envelopeId?: string;
accountId: string; // Link transaction to specific account
accountId: string;
amount: number;
description: string;
date: Date | string; // Support both Date objects and ISO strings
date: Date | string;
category?: string;
}
@@ -39,93 +39,51 @@ interface UserData {
envelopes: Envelope[];
transactions: Transaction[];
setupCompleted: boolean;
goals?: any[];
transactionRules?: any[];
}
function getAuthHeaders(): HeadersInit {
const token = AuthService.getSessionToken();
if (!token) throw new Error('Not authenticated');
return { 'Content-Type': 'application/json', 'x-session-token': token };
}
export class DataService {
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');
}
}
}
static getUserId(): string | null {
if (this.currentUserId) return this.currentUserId;
if (typeof window !== 'undefined') {
this.currentUserId = localStorage.getItem('userId');
}
return this.currentUserId;
}
static clearUserId() {
this.setUserId(null);
}
// kept for legacy callers in page.tsx — no-op now that session handles identity
static setUserId(_userId: string | null) {}
static getUserId(): string | null { return AuthService.getCurrentUser()?.userId ?? null; }
static clearUserId() {}
static async saveUserData(data: UserData): Promise<void> {
const userId = this.getUserId();
if (!userId) throw new Error('No user ID set');
try {
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;
const response = await fetch('/api/data', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to save data');
}
}
static async loadUserData(): Promise<UserData | null> {
const userId = this.getUserId();
if (!userId) throw new Error('No user ID set');
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');
}
const result = await response.json();
return result.data;
} catch (error) {
console.error('Error loading user data:', error);
throw error;
const response = await fetch('/api/data', {
method: 'GET',
headers: getAuthHeaders(),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to load data');
}
const result = await response.json();
return result.data;
}
static async updateUserData(updates: Partial<UserData>): Promise<void> {
try {
const currentData = await this.loadUserData();
if (currentData) {
const updatedData = { ...currentData, ...updates };
await this.saveUserData(updatedData);
}
} catch (error) {
console.error('Error updating user data:', error);
throw error;
const currentData = await this.loadUserData();
if (currentData) {
await this.saveUserData({ ...currentData, ...updates });
}
}
}
}
+162 -118
View File
@@ -1,26 +1,26 @@
import fs from 'fs';
import path from 'path';
import { promises as fsPromises } from 'fs';
import { createHash } from 'crypto';
import bcrypt from 'bcryptjs';
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 SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
// 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');
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE]) {
try {
await fsPromises.access(file);
} catch {
await fsPromises.writeFile(file, JSON.stringify({}), 'utf-8');
}
}
} catch (error) {
console.error('Error initializing directories:', error);
@@ -39,17 +39,32 @@ interface Users {
[email: string]: UserCredentials;
}
// Lock mechanism for concurrent access prevention
interface Session {
userId: string;
email: string;
expiresAt: string;
}
interface Sessions {
[token: string]: Session;
}
interface ResetToken {
email: string;
expiresAt: string;
}
interface ResetTokens {
[token: string]: ResetToken;
}
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;
}
if (existingLock) await existingLock;
let releaseLock: () => void;
const lockPromise = new Promise<void>((resolve) => {
releaseLock = () => {
@@ -57,109 +72,150 @@ class FileLock {
resolve();
};
});
this.locks.set(key, lockPromise);
return releaseLock!;
}
}
// Simple password hashing (in production, use bcrypt or similar)
async function hashPassword(password: string): Promise<string> {
// 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('');
}
function validatePasswordStrength(password: string): void {
if (password.length < 8) throw new Error('Password must be at least 8 characters');
}
// Fallback for environments without crypto.subtle
return createHash('sha256').update(password, 'utf8').digest('hex');
async function readJson<T>(file: string): Promise<T> {
const content = await fsPromises.readFile(file, 'utf-8');
return JSON.parse(content) as T;
}
async function writeJson(file: string, data: unknown): Promise<void> {
await fsPromises.writeFile(file, JSON.stringify(data, null, 2), 'utf-8');
}
export class FileStorageService {
// User Authentication
static async registerUser(email: string, password: string): Promise<{ userId: string; email: string }> {
static async registerUser(email: string, password: string): Promise<{ userId: string; email: string; sessionToken: string }> {
validatePasswordStrength(password);
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 already exists');
}
const users = await readJson<Users>(USERS_FILE);
if (users[email]) throw new Error('User already exists');
const userId = crypto.randomUUID();
const passwordHash = await hashPassword(password);
const passwordHash = await bcrypt.hash(password, 12);
users[email] = {
email,
passwordHash,
userId,
};
users[email] = { email, passwordHash, userId };
await writeJson(USERS_FILE, users);
await fsPromises.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8');
const encryptedData = await encryptData(JSON.stringify({
accounts: [], envelopes: [], transactions: [], setupCompleted: false,
}), ENCRYPTION_KEY);
await writeJson(path.join(DATA_DIR, `${userId}.json`), {
data: encryptedData, lastUpdated: new Date().toISOString(),
});
// 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 };
const sessionToken = await this._createSession(userId, email);
return { userId, email, sessionToken };
} finally {
releaseLock();
}
}
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string }> {
const content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string; sessionToken: string }> {
const users = await readJson<Users>(USERS_FILE);
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');
}
// Always run bcrypt compare to prevent timing attacks
const hashToCompare = user?.passwordHash ?? '$2a$12$invalidhashpadding000000000000000000000000000000000000000';
const valid = await bcrypt.compare(password, hashToCompare);
return { userId: user.userId, email: user.email };
if (!user || !valid) throw new Error('Invalid credentials');
const sessionToken = await this._createSession(user.userId, email);
return { userId: user.userId, email: user.email, sessionToken };
}
static async validateSession(token: string): Promise<{ userId: string; email: string } | null> {
try {
const sessions = await readJson<Sessions>(SESSIONS_FILE);
const session = sessions[token];
if (!session) return null;
if (new Date(session.expiresAt) < new Date()) {
// Expired — clean up lazily
delete sessions[token];
await writeJson(SESSIONS_FILE, sessions);
return null;
}
return { userId: session.userId, email: session.email };
} catch {
return null;
}
}
static async deleteSession(token: string): Promise<void> {
try {
const sessions = await readJson<Sessions>(SESSIONS_FILE);
delete sessions[token];
await writeJson(SESSIONS_FILE, sessions);
} catch {
// ignore
}
}
static async generatePasswordResetToken(email: string): Promise<string> {
const users = await readJson<Users>(USERS_FILE);
if (!users[email]) throw new Error('User not found');
const releaseLock = await FileLock.acquire('reset-tokens');
try {
const tokens = await readJson<ResetTokens>(RESET_TOKENS_FILE);
const token = crypto.randomUUID();
tokens[token] = { email, expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString() };
await writeJson(RESET_TOKENS_FILE, tokens);
return token;
} finally {
releaseLock();
}
}
static async resetPassword(token: string, newPassword: string): Promise<void> {
validatePasswordStrength(newPassword);
const releaseLock = await FileLock.acquire('users-file');
try {
const tokens = await readJson<ResetTokens>(RESET_TOKENS_FILE);
const record = tokens[token];
if (!record) throw new Error('Invalid or expired reset token');
if (new Date(record.expiresAt) < new Date()) throw new Error('Reset token has expired');
const users = await readJson<Users>(USERS_FILE);
if (!users[record.email]) throw new Error('User not found');
users[record.email].passwordHash = await bcrypt.hash(newPassword, 12);
await writeJson(USERS_FILE, users);
delete tokens[token];
await writeJson(RESET_TOKENS_FILE, tokens);
} finally {
releaseLock();
}
}
// 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}`);
try {
// Serialize dates
const serialized = {
...data,
transactions: (data.transactions || []).map((t: any) => ({
...t,
date: t.date instanceof Date ? t.date.toISOString() : t.date
}))
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(),
};
await fsPromises.writeFile(userDataFile, JSON.stringify(fileData, null, 2), 'utf-8');
await writeJson(userDataFile, { data: encryptedData, lastUpdated: new Date().toISOString() });
} finally {
releaseLock();
}
@@ -167,75 +223,63 @@ export class FileStorageService {
static async loadUserData(userId: string): Promise<any> {
const userDataFile = path.join(DATA_DIR, `${userId}.json`);
try {
const fileData = JSON.parse(await fsPromises.readFile(userDataFile, 'utf-8'));
const fileData = await readJson<{ data: string }>(userDataFile);
const decryptedData = await decryptData(fileData.data, ENCRYPTION_KEY);
const parsed = JSON.parse(decryptedData);
// Ensure consistent date handling - always return ISO strings
return {
...parsed,
transactions: (parsed.transactions || []).map((t: any) => ({
...t,
date: typeof t.date === 'string' ? t.date : new Date(t.date).toISOString()
}))
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,
};
return { accounts: [], envelopes: [], transactions: [], setupCompleted: false };
}
throw error;
}
}
static async deleteUser(userId: string): Promise<void> {
static async deleteUser(userId: string, sessionToken?: string): Promise<void> {
const releaseLock = await FileLock.acquire('users-file');
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 content = await fsPromises.readFile(USERS_FILE, 'utf-8');
const users: Users = JSON.parse(content);
const users = await readJson<Users>(USERS_FILE);
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');
await writeJson(USERS_FILE, users);
}
if (sessionToken) await this.deleteSession(sessionToken);
} finally {
releaseLock();
}
}
static async resetPassword(email: string, newPassword: string): Promise<void> {
const releaseLock = await FileLock.acquire('users-file');
private static async _createSession(userId: string, email: string): Promise<string> {
const releaseLock = await FileLock.acquire('sessions-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');
const sessions = await readJson<Sessions>(SESSIONS_FILE);
const token = crypto.randomUUID() + '-' + crypto.randomUUID();
sessions[token] = {
userId,
email,
expiresAt: new Date(Date.now() + SESSION_TTL_MS).toISOString(),
};
await writeJson(SESSIONS_FILE, sessions);
return token;
} finally {
releaseLock();
}