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:
1 parent
8814423fa6
commit
20ea25f4de
19 files changed
+1685
-1486
No files matched your search
+72
-102
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user