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

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