Add TOTP 2FA and retroactive rule application

- TOTP-based two-factor authentication (RFC 6238, HMAC-SHA1): setup, enable, disable in Settings → Password & Security
- Login flow: password verified first, then temp token issued for 5-min TOTP window before full session is granted
- QR code generated server-side via `qrcode` package for scanning with any authenticator app
- "Run Rules Now" button in TransactionRulesManager applies auto-categorization rules to existing uncategorized expenses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 09:20:44 -04:00
1 parent 5339e9818a
commit 52293d7db3
15 files changed
+877 -90

No files matched your search

+98 -78
View File
@@ -18,6 +18,11 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 2FA step
const [totpStep, setTotpStep] = useState(false);
const [tempToken, setTempToken] = useState('');
const [totpCode, setTotpCode] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -27,24 +32,41 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
if (isLogin) {
const user = await AuthService.signIn(email, password);
DataService.setUserId(user.userId);
onAuthSuccess();
onClose();
} else {
if (password !== confirmPassword) {
throw new Error('Passwords do not match');
}
if (password !== confirmPassword) throw new Error('Passwords do not match');
const user = await AuthService.signUp(email, password);
DataService.setUserId(user.userId);
onAuthSuccess();
onClose();
}
} catch (err: any) {
if (err.requiresTOTP) {
setTempToken(err.tempToken);
setTotpStep(true);
} else {
let msg = err.message;
if (msg === 'Invalid credentials') msg = 'The email or password you entered is incorrect.';
else if (msg === 'User already exists') msg = 'An account with this email already exists. Try signing in instead.';
setError(msg);
}
} finally {
setLoading(false);
}
};
const handleTOTPSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const user = await AuthService.signInWithTOTP(tempToken, totpCode.trim());
DataService.setUserId(user.userId);
onAuthSuccess();
onClose();
} catch (error: any) {
// Provide more user-friendly error messages
let errorMessage = error.message;
if (error.message === 'Invalid credentials') {
errorMessage = 'The email or password you entered is incorrect. Please try again.';
} else if (error.message === 'User already exists') {
errorMessage = 'An account with this email already exists. Please try logging in instead.';
}
setError(errorMessage);
} catch (err: any) {
setError(err.message || '2FA verification failed');
} finally {
setLoading(false);
}
@@ -57,85 +79,83 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
<div className="bg-white rounded-lg p-8 max-w-md w-full shadow-xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">
{isLogin ? 'Sign In' : 'Create Account'}
{totpStep ? 'Two-Factor Authentication' : isLogin ? 'Sign In' : 'Create Account'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-1"
>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={6}
/>
</div>
{!isLogin && (
{totpStep ? (
<form onSubmit={handleTOTPSubmit} className="space-y-4">
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
🔐 Enter the 6-digit code from your authenticator app.
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm Password
</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Authentication Code</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
type="text"
inputMode="numeric"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 text-center text-2xl tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={6}
/>
</div>
)}
{error && <div className="text-red-600 text-sm">{error}</div>}
<button type="submit" disabled={loading || totpCode.length !== 6}
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 disabled:opacity-50 transition-opacity"
style={{ backgroundColor: 'var(--color-primary-blue)' }}>
{loading ? 'Verifying…' : 'Verify'}
</button>
<button type="button" onClick={() => { setTotpStep(false); setTotpCode(''); setError(''); }}
className="w-full text-gray-600 hover:text-gray-800 text-sm">
← Back to sign in
</button>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required minLength={6} />
</div>
{!isLogin && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required minLength={6} />
</div>
)}
{error && <div className="text-red-600 text-sm">{error}</div>}
<button type="submit" disabled={loading}
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 focus:outline-none focus:ring-2 disabled:opacity-50 transition-opacity"
style={{ backgroundColor: 'var(--color-primary-blue)' }}>
{loading ? 'Please wait…' : isLogin ? 'Sign In' : 'Create Account'}
</button>
</form>
)}
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading}
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 focus:outline-none focus:ring-2 disabled:opacity-50 transition-opacity"
style={{ backgroundColor: 'var(--color-primary-blue)' }}
>
{loading ? 'Please wait...' : (isLogin ? 'Sign In' : 'Create Account')}
</button>
</form>
<div className="mt-4 text-center">
<button
onClick={() => setIsLogin(!isLogin)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
{!totpStep && (
<div className="mt-4 text-center">
<button onClick={() => setIsLogin(!isLogin)} className="text-blue-600 hover:text-blue-800 text-sm">
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
)}
</div>
</div>
);
}
}
+161 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { AuthService } from '../lib/authService';
import { DataService } from '../lib/dataService';
@@ -21,6 +21,87 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loading, setLoading] = useState(false);
// 2FA state
const [twoFAEnabled, setTwoFAEnabled] = useState(false);
const [twoFASetupData, setTwoFASetupData] = useState<{ secret: string; qrDataUrl: string } | null>(null);
const [twoFACode, setTwoFACode] = useState('');
const [disableCode, setDisableCode] = useState('');
const [twoFALoading, setTwoFALoading] = useState(false);
const [twoFAMessage, setTwoFAMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
const token = AuthService.getSessionToken();
if (!token) return;
fetch('/api/auth/2fa/status', { headers: { 'x-session-token': token } })
.then(r => r.json())
.then(d => setTwoFAEnabled(d.enabled ?? false))
.catch(() => {});
}, []);
const handleSetup2FA = async () => {
setTwoFALoading(true);
setTwoFAMessage(null);
try {
const token = AuthService.getSessionToken();
const res = await fetch('/api/auth/2fa/setup', { method: 'POST', headers: { 'x-session-token': token ?? '' } });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setTwoFASetupData({ secret: data.secret, qrDataUrl: data.qrDataUrl });
setTwoFACode('');
} catch (e: any) {
setTwoFAMessage({ type: 'error', text: e.message });
} finally {
setTwoFALoading(false);
}
};
const handleVerify2FA = async (e: React.FormEvent) => {
e.preventDefault();
setTwoFALoading(true);
setTwoFAMessage(null);
try {
const token = AuthService.getSessionToken();
const res = await fetch('/api/auth/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
body: JSON.stringify({ code: twoFACode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setTwoFAEnabled(true);
setTwoFASetupData(null);
setTwoFACode('');
setTwoFAMessage({ type: 'success', text: '2FA enabled successfully!' });
} catch (e: any) {
setTwoFAMessage({ type: 'error', text: e.message });
} finally {
setTwoFALoading(false);
}
};
const handleDisable2FA = async (e: React.FormEvent) => {
e.preventDefault();
setTwoFALoading(true);
setTwoFAMessage(null);
try {
const token = AuthService.getSessionToken();
const res = await fetch('/api/auth/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
body: JSON.stringify({ code: disableCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setTwoFAEnabled(false);
setDisableCode('');
setTwoFAMessage({ type: 'success', text: '2FA disabled.' });
} catch (e: any) {
setTwoFAMessage({ type: 'error', text: e.message });
} finally {
setTwoFALoading(false);
}
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
@@ -223,7 +304,8 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
{/* Password Tab */}
{activeTab === 'password' && (
<div className="bg-white rounded-lg shadow-sm border p-6 max-w-md">
<div className="space-y-6 max-w-md">
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-1">Change Password</h3>
<p className="text-sm text-gray-500 mb-4">Signed in as <strong>{user.email}</strong></p>
<form onSubmit={handlePasswordChange} className="space-y-4">
@@ -251,6 +333,83 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
</button>
</form>
</div>
{/* 2FA Section */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
<p className="text-sm text-gray-500 mt-0.5">
{twoFAEnabled ? '🔐 2FA is enabled — your account is protected.' : 'Add an extra layer of security with an authenticator app.'}
</p>
</div>
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${twoFAEnabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{twoFAEnabled ? 'ON' : 'OFF'}
</span>
</div>
{twoFAMessage && (
<div className={`mb-4 p-3 rounded-lg text-sm ${twoFAMessage.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
{twoFAMessage.text}
</div>
)}
{!twoFAEnabled && !twoFASetupData && (
<button onClick={handleSetup2FA} disabled={twoFALoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-opacity text-sm">
{twoFALoading ? 'Generating…' : 'Enable 2FA'}
</button>
)}
{!twoFAEnabled && twoFASetupData && (
<div className="space-y-4">
<p className="text-sm text-gray-700">Scan this QR code with <strong>Google Authenticator</strong>, <strong>Authy</strong>, or any TOTP app:</p>
<div className="flex justify-center">
<img src={twoFASetupData.qrDataUrl} alt="2FA QR Code" className="w-48 h-48 border border-gray-200 rounded-lg" />
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Or enter this code manually:</p>
<code className="block bg-gray-100 px-3 py-2 rounded text-sm font-mono break-all text-gray-800 select-all">{twoFASetupData.secret}</code>
</div>
<form onSubmit={handleVerify2FA} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Enter the 6-digit code to confirm</label>
<input type="text" inputMode="numeric"
value={twoFACode}
onChange={e => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-center text-xl tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
required />
</div>
<div className="flex gap-2">
<button type="button" onClick={() => { setTwoFASetupData(null); setTwoFACode(''); }}
className="flex-1 bg-gray-100 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-200 text-sm">Cancel</button>
<button type="submit" disabled={twoFALoading || twoFACode.length !== 6}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm">
{twoFALoading ? 'Verifying…' : 'Verify & Enable'}
</button>
</div>
</form>
</div>
)}
{twoFAEnabled && (
<form onSubmit={handleDisable2FA} className="space-y-3">
<p className="text-sm text-gray-600">Enter your current 2FA code to disable:</p>
<input type="text" inputMode="numeric"
value={disableCode}
onChange={e => setDisableCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-center text-xl tracking-widest focus:outline-none focus:ring-2 focus:ring-red-400 bg-white text-gray-900"
required />
<button type="submit" disabled={twoFALoading || disableCode.length !== 6}
className="w-full bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 disabled:opacity-50 text-sm">
{twoFALoading ? 'Disabling…' : 'Disable 2FA'}
</button>
</form>
)}
</div>
</div>
)}
{/* Export Tab */}
+47 -1
View File
@@ -5,17 +5,22 @@ import type { TransactionRule } from '../app/page';
interface Envelope { id: string; name: string; color: string }
interface Transaction { id: string; description: string; amount: number; envelopeId?: string }
interface Props {
rules: TransactionRule[];
envelopes: Envelope[];
onRulesChange: (rules: TransactionRule[]) => void;
transactions?: Transaction[];
onApplyToExisting?: (updated: Transaction[]) => void;
}
export default function TransactionRulesManager({ rules, envelopes, onRulesChange }: Props) {
export default function TransactionRulesManager({ rules, envelopes, onRulesChange, transactions = [], onApplyToExisting }: Props) {
const [keyword, setKeyword] = useState('');
const [envelopeId, setEnvelopeId] = useState(envelopes[0]?.id ?? '');
const [matchCase, setMatchCase] = useState(false);
const [error, setError] = useState('');
const [applyResult, setApplyResult] = useState<string | null>(null);
const handleAdd = () => {
if (!keyword.trim()) { setError('Keyword is required.'); return; }
@@ -26,6 +31,26 @@ export default function TransactionRulesManager({ rules, envelopes, onRulesChang
const handleDelete = (id: string) => onRulesChange(rules.filter(r => r.id !== id));
const handleApplyToExisting = () => {
if (!onApplyToExisting || rules.length === 0) return;
let matched = 0;
const updated = transactions.map(tx => {
if (tx.envelopeId || tx.amount >= 0) return tx;
for (const rule of rules) {
const haystack = rule.matchCase ? tx.description : tx.description.toLowerCase();
const needle = rule.matchCase ? rule.keyword : rule.keyword.toLowerCase();
if (haystack.includes(needle) && envelopes.some(e => e.id === rule.envelopeId)) {
matched++;
return { ...tx, envelopeId: rule.envelopeId };
}
}
return tx;
});
onApplyToExisting(updated);
setApplyResult(`Applied rules: ${matched} transaction${matched !== 1 ? 's' : ''} categorized.`);
setTimeout(() => setApplyResult(null), 4000);
};
return (
<div>
<div className="mb-6">
@@ -107,6 +132,27 @@ export default function TransactionRulesManager({ rules, envelopes, onRulesChang
</div>
)}
{onApplyToExisting && rules.length > 0 && (
<div className="mt-4 bg-white shadow rounded-lg p-5 flex items-center justify-between gap-4">
<div>
<p className="font-medium text-gray-900 text-sm">Apply rules to existing transactions</p>
<p className="text-xs text-gray-500 mt-0.5">
Categorize uncategorized expenses already in your history using the current rules.
</p>
</div>
<button onClick={handleApplyToExisting}
className="flex-shrink-0 bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 text-sm font-medium whitespace-nowrap">
Run Rules Now
</button>
</div>
)}
{applyResult && (
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800">
✓ {applyResult}
</div>
)}
<div className="mt-4 p-4 bg-blue-50 rounded-lg text-sm text-blue-800">
<strong>How rules work:</strong> When you add a new expense, its description is checked against all rules in order.
The first matching rule wins. Rules only apply to expenses (negative amounts) without an already-assigned envelope.