Add account sharing, Get Paid schedule, recurring manager, overspend warnings, mobile nav

Account sharing:
- fileStorage: add shares.json with createShare/listShares/getShareData/revokeShare
- /api/share/account: real implementation using session token
- /api/share/list: GET endpoint returning owned + received shares
- /api/share/data: GET endpoint to fetch a shared account's data
- SharedAccountManager: use session token, load existing shares, revoke support
- AccountManagement: render SharedAccountManager per-account
- page.tsx: load received shares on login, show "Shared with me" cards in Accounts view

Get Paid schedule:
- PaycheckSchedule type: weekly/biweekly/semimonthly/monthly with day config
- computeNextPayday(): calculates next payday from today
- Schedule UI in GetPaid modal with live "Next payday: ..." preview
- Schedule persisted to localStorage template

Recurring transaction manager:
- Fix biweekly support (was missing)
- Show "due now" vs "upcoming" with amber badge for overdue
- Render in Transactions view; onApplyRecurring creates new transactions + updates lastAppliedDate

Envelope overspending:
- Red warning banner at top of Envelopes view listing overspent envelopes
- Red ring on individual overspent envelope cards

Mobile layout:
- Fixed bottom tab bar (Home/Accounts/Txns/Envelopes/More) on sm: and below
- Active tab highlighted in blue
- Main content padded to avoid being hidden by bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 09:10:10 -04:00
1 parent 18fe6b5141
commit 5339e9818a
9 files changed
+518 -161

No files matched your search

+33 -22
View File
@@ -1,32 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { FileStorageService } from '@/lib/fileStorage';
export async function POST(request: NextRequest) {
try {
const userId = request.headers.get('x-user-id');
const { accountId, email, role } = await request.json();
const token = request.headers.get('x-session-token');
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (!userId || !accountId || !email) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
const session = await FileStorageService.validateSession(token);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { accountId, email, role } = await request.json();
if (!accountId || !email || !role) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (email === session.email) {
return NextResponse.json({ error: 'Cannot share an account with yourself' }, { status: 400 });
}
// TODO: Implement actual sharing logic
// 1. Validate email exists
// 2. Create sharing record in database
// 3. Send notification to shared user
// 4. Add shared account to their account list
return NextResponse.json({
success: true,
message: `Account shared with ${email} (${role} access)`,
note: 'Sharing feature coming soon - for now this is a placeholder',
});
const shareId = await FileStorageService.createShare(session.userId, session.email, email, accountId, role);
return NextResponse.json({ success: true, shareId, message: `Account shared with ${email}` });
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Failed to share account' },
{ status: 500 }
);
return NextResponse.json({ error: error.message || 'Failed to share account' }, { status: 400 });
}
}
export async function DELETE(request: NextRequest) {
try {
const token = request.headers.get('x-session-token');
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const session = await FileStorageService.validateSession(token);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { shareId } = await request.json();
if (!shareId) return NextResponse.json({ error: 'Missing shareId' }, { status: 400 });
await FileStorageService.revokeShare(shareId, session.userId);
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to revoke share' }, { status: 400 });
}
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { FileStorageService } from '@/lib/fileStorage';
export async function GET(request: NextRequest) {
try {
const token = request.headers.get('x-session-token');
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const session = await FileStorageService.validateSession(token);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const shareId = request.nextUrl.searchParams.get('shareId');
if (!shareId) return NextResponse.json({ error: 'Missing shareId' }, { status: 400 });
const data = await FileStorageService.getShareData(shareId, session.email);
if (!data) return NextResponse.json({ error: 'Share not found or access denied' }, { status: 404 });
return NextResponse.json(data);
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to load shared data' }, { status: 500 });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { FileStorageService } from '@/lib/fileStorage';
export async function GET(request: NextRequest) {
try {
const token = request.headers.get('x-session-token');
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const session = await FileStorageService.validateSession(token);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { received, owned } = await FileStorageService.listShares(session.email, session.userId);
return NextResponse.json({ received, owned });
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to list shares' }, { status: 500 });
}
}
+104 -4
View File
@@ -15,6 +15,8 @@ import Settings from '../components/Settings';
import GetPaid from '../components/GetPaid';
import GoalsManager from '../components/GoalsManager';
import TransactionRulesManager from '../components/TransactionRulesManager';
import RecurringTransactionManager from '../components/RecurringTransactionManager';
import SharedAccountManager from '../components/SharedAccountManager';
interface User { userId: string; email: string }
@@ -112,6 +114,7 @@ export default function BudgetDashboard() {
const [goals, setGoals] = useState<Goal[]>([]);
const [transactionRules, setTransactionRules] = useState<TransactionRule[]>([]);
const [setupCompleted, setSetupCompleted] = useState(false);
const [sharedAccounts, setSharedAccounts] = useState<Array<{ shareId: string; ownerEmail: string; account: Account; role: string }>>([]);
// Transaction filters
const [txSearch, setTxSearch] = useState('');
@@ -189,6 +192,24 @@ export default function BudgetDashboard() {
// Auto-apply due recurring transactions
autoApplyRecurring(txnsWithDates);
// Load accounts shared with me
const sessionToken = AuthService.getSessionToken();
if (sessionToken) {
fetch('/api/share/list', { headers: { 'x-session-token': sessionToken } })
.then(r => r.json())
.then(async (data) => {
const received: any[] = data.received ?? [];
const loaded = await Promise.all(received.map(async (share: any) => {
const res = await fetch(`/api/share/data?shareId=${share.shareId}`, { headers: { 'x-session-token': sessionToken } });
if (!res.ok) return null;
const d = await res.json();
return { shareId: share.shareId, ownerEmail: share.ownerEmail, account: d.account, role: share.role };
}));
setSharedAccounts(loaded.filter(Boolean) as any);
})
.catch(() => {});
}
} else {
setShowSetupWizard(true);
}
@@ -200,6 +221,7 @@ export default function BudgetDashboard() {
setAccounts([]); setEnvelopes([]); setTransactions([]);
setGoals([]); setTransactionRules([]);
setSetupCompleted(false); setShowSetupWizard(false);
setSharedAccounts([]);
}
setLoading(false);
});
@@ -269,6 +291,25 @@ export default function BudgetDashboard() {
});
};
const handleApplyRecurring = (dueTxns: Transaction[]) => {
const now = new Date();
const newTxns: Transaction[] = dueTxns.map(t => ({
...t,
id: crypto.randomUUID(),
date: now,
lastAppliedDate: now.toISOString(),
}));
setTransactions(prev => {
const updated = prev.map(t =>
dueTxns.find(d => d.id === t.id) ? { ...t, lastAppliedDate: now.toISOString() } : t
);
const next = [...updated, ...newTxns];
setEnvelopes(calculateEnvelopeSpending(envelopes, next));
setAccounts(calculateAccountBalances(accounts, next));
return next;
});
};
// ── Event handlers ───────────────────────────────────────────────────────────
const handleTransactionsAdded = (newTxns: Transaction[]) => {
@@ -778,6 +819,11 @@ export default function BudgetDashboard() {
transactions={transactions}
/>
<RecurringTransactionManager
transactions={transactions}
onApplyRecurring={handleApplyRecurring}
/>
{/* Filter bar */}
<div className="bg-white shadow rounded-lg p-4 mb-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<input
@@ -877,17 +923,34 @@ export default function BudgetDashboard() {
</div>
);
const renderEnvelopes = () => (
const renderEnvelopes = () => {
const overspentEnvs = envelopes.filter(e => {
const incomeAlloc = transactions.filter(t => t.envelopeId === e.id && t.amount > 0).reduce((s, t) => s + t.amount, 0);
return e.allocated + incomeAlloc > 0 && e.spent > e.allocated + incomeAlloc;
});
return (
<div className="px-4 py-6 sm:px-0">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Envelopes</h2>
{overspentEnvs.length > 0 && (
<div className="mb-6 bg-red-50 border border-red-300 rounded-lg p-4 flex items-start gap-3">
<span className="text-red-500 text-xl flex-shrink-0">⚠️</span>
<div>
<p className="font-semibold text-red-800">Budget exceeded</p>
<p className="text-sm text-red-700 mt-0.5">
{overspentEnvs.map(e => e.name).join(', ')} {overspentEnvs.length === 1 ? 'is' : 'are'} over budget. Consider adjusting allocations or reviewing recent spending.
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{envelopes.map(env => {
const incomeAlloc = transactions.filter(t => t.envelopeId === env.id && t.amount > 0).reduce((s, t) => s + t.amount, 0);
const totalAlloc = env.allocated + incomeAlloc;
const remaining = totalAlloc - env.spent;
const pct = totalAlloc > 0 ? Math.min(100, Math.round((env.spent / totalAlloc) * 100)) : 0;
const isOver = totalAlloc > 0 && env.spent > totalAlloc;
return (
<div key={env.id} className="bg-white shadow rounded-lg overflow-hidden">
<div key={env.id} className={`bg-white shadow rounded-lg overflow-hidden ${isOver ? 'ring-2 ring-red-400' : ''}`}>
<div className="h-1" style={{ backgroundColor: env.color }} />
<div className="p-5">
<div className="flex justify-between items-center mb-3">
@@ -921,7 +984,7 @@ export default function BudgetDashboard() {
</button>
</div>
</div>
);
);};
return (
<div className="min-h-screen bg-gray-50">
@@ -991,7 +1054,7 @@ export default function BudgetDashboard() {
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8 pb-20 sm:pb-6">
{loading ? (
<div className="text-center py-20 text-gray-400">Loading…</div>
) : !user ? (
@@ -1056,6 +1119,24 @@ export default function BudgetDashboard() {
onAccountUpdate={handleAccountUpdate}
onAccountDelete={id => setAccounts(prev => prev.filter(a => a.id !== id))}
/>
{sharedAccounts.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-semibold text-gray-700 mb-3">Shared with me</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sharedAccounts.map(sa => (
<div key={sa.shareId} className="bg-white shadow rounded-lg p-5 border border-blue-100">
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-gray-900">{sa.account?.name}</span>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded capitalize">{sa.role}</span>
</div>
<p className="text-xs text-gray-500 mb-1">Shared by {sa.ownerEmail}</p>
<p className="text-xl font-bold text-blue-700">${(sa.account?.balance ?? 0).toFixed(2)}</p>
<p className="text-xs text-gray-400 mt-1 capitalize">{sa.account?.type?.replace('_', ' ')}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
@@ -1128,6 +1209,25 @@ export default function BudgetDashboard() {
accounts={accounts.map(a => ({ id: a.id, name: a.name }))}
/>
)}
{/* Mobile bottom nav */}
{user && setupCompleted && (
<nav className="sm:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40 flex">
{([
{ view: 'dashboard', icon: '🏠', label: 'Home' },
{ view: 'accounts', icon: '🏦', label: 'Accounts' },
{ view: 'transactions', icon: '↕', label: 'Txns' },
{ view: 'envelopes', icon: '📦', label: 'Envelopes' },
{ view: 'settings', icon: '⚙️', label: 'More' },
] as { view: ViewMode; icon: string; label: string }[]).map(({ view, icon, label }) => (
<button key={view} onClick={() => setCurrentView(view)}
className={`flex-1 flex flex-col items-center py-2 gap-0.5 text-xs transition-colors ${currentView === view ? 'text-blue-600' : 'text-gray-500'}`}>
<span className="text-lg leading-none">{icon}</span>
<span>{label}</span>
</button>
))}
</nav>
)}
{editingEnvelope && (
<EnvelopeEdit
envelope={editingEnvelope}
+5 -1
View File
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import SharedAccountManager from './SharedAccountManager';
interface Account {
id: string;
@@ -165,7 +166,8 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
) : (
<div className="divide-y divide-gray-200">
{(accounts || []).map((account) => (
<div key={account.id} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50" role="article" aria-label={`Account: ${account.name}`}>
<div key={account.id} className="px-6 py-4 hover:bg-gray-50" role="article" aria-label={`Account: ${account.name}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className={`w-4 h-4 rounded-full ${account.color}`} role="img" aria-label={`Color indicator: ${account.color}`}></div>
<div>
@@ -210,6 +212,8 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
</button>
</div>
</div>
</div>
<SharedAccountManager accountId={account.id} accountName={account.name} />
</div>
))}
</div>
+97 -11
View File
@@ -35,12 +35,20 @@ interface Transaction {
type DistributionMethod = 'proportional' | 'equal' | 'custom';
type AllocationMap = Record<string, number>;
interface PaycheckSchedule {
frequency: 'weekly' | 'biweekly' | 'semimonthly' | 'monthly';
dayOfWeek?: number; // 0-6, for weekly/biweekly
dayOfMonth?: number; // 1-31, for monthly/semimonthly
dayOfMonth2?: number; // for semimonthly second date
}
interface PaycheckTemplate {
amount: number;
selectedAccountId: string;
description: string;
distributionMethod: DistributionMethod;
customAllocations: AllocationMap;
schedule?: PaycheckSchedule;
}
interface GetPaidProps {
@@ -70,6 +78,8 @@ export default function GetPaid({
const [defaultAmountInput, setDefaultAmountInput] = useState('');
const [saveTemplate, setSaveTemplate] = useState(false);
const [savedTemplate, setSavedTemplate] = useState<PaycheckTemplate | null>(null);
const [showSchedule, setShowSchedule] = useState(false);
const [schedule, setSchedule] = useState<PaycheckSchedule>({ frequency: 'biweekly', dayOfWeek: 5 });
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -122,12 +132,37 @@ export default function GetPaid({
const applySavedTemplate = () => {
if (!savedTemplate) return;
setAmount(savedTemplate.amount?.toString() || '');
setSelectedAccountId(savedTemplate.selectedAccountId || '');
setDescription(savedTemplate.description || 'Paycheck');
setDistributionMethod(savedTemplate.distributionMethod || 'proportional');
setCustomAllocations(savedTemplate.customAllocations || {});
if (savedTemplate.schedule) setSchedule(savedTemplate.schedule);
};
const computeNextPayday = (sched: PaycheckSchedule): Date => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const next = new Date(today);
if (sched.frequency === 'weekly' || sched.frequency === 'biweekly') {
const dow = sched.dayOfWeek ?? 5;
const diff = (dow - today.getDay() + 7) % 7 || (sched.frequency === 'biweekly' ? 14 : 7);
next.setDate(today.getDate() + diff);
} else if (sched.frequency === 'semimonthly') {
const d1 = sched.dayOfMonth ?? 1;
const d2 = sched.dayOfMonth2 ?? 15;
const thisMonthD1 = new Date(today.getFullYear(), today.getMonth(), d1);
const thisMonthD2 = new Date(today.getFullYear(), today.getMonth(), d2);
const nextMonthD1 = new Date(today.getFullYear(), today.getMonth() + 1, d1);
const candidates = [thisMonthD1, thisMonthD2, nextMonthD1].filter(d => d > today);
return candidates.sort((a, b) => a.getTime() - b.getTime())[0] ?? nextMonthD1;
} else {
const dom = sched.dayOfMonth ?? 1;
next.setDate(dom);
if (next <= today) next.setMonth(next.getMonth() + 1);
}
return next;
};
const handleSubmit = (event: React.FormEvent) => {
@@ -183,6 +218,7 @@ export default function GetPaid({
description,
distributionMethod,
customAllocations: previewAllocations,
schedule: showSchedule ? schedule : undefined,
};
localStorage.setItem('paycheckTemplate', JSON.stringify(template));
setSavedTemplate(template);
@@ -384,17 +420,67 @@ export default function GetPaid({
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="saveTemplate"
checked={saveTemplate}
onChange={(event) => setSaveTemplate(event.target.checked)}
className="mr-2"
/>
<label htmlFor="saveTemplate" className="text-sm text-gray-700">
Save this information to use next time
<div className="space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="saveTemplate" checked={saveTemplate} onChange={e => setSaveTemplate(e.target.checked)} className="rounded" />
<span className="text-sm text-gray-700">Save this information to use next time</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showSchedule} onChange={e => setShowSchedule(e.target.checked)} className="rounded" />
<span className="text-sm text-gray-700">Set a paycheck schedule (shows next payday)</span>
</label>
{showSchedule && (
<div className="ml-6 p-3 bg-green-50 border border-green-200 rounded-lg space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
<select value={schedule.frequency} onChange={e => setSchedule(s => ({ ...s, frequency: e.target.value as PaycheckSchedule['frequency'] }))}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900">
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly (every 2 weeks)</option>
<option value="semimonthly">Semi-monthly (twice a month)</option>
<option value="monthly">Monthly</option>
</select>
</div>
{(schedule.frequency === 'weekly' || schedule.frequency === 'biweekly') && (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Payday</label>
<select value={schedule.dayOfWeek ?? 5} onChange={e => setSchedule(s => ({ ...s, dayOfWeek: parseInt(e.target.value) }))}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900">
{['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'].map((d, i) => (
<option key={i} value={i}>{d}</option>
))}
</select>
</div>
)}
{schedule.frequency === 'semimonthly' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">First payday</label>
<input type="number" min={1} max={31} value={schedule.dayOfMonth ?? 1}
onChange={e => setSchedule(s => ({ ...s, dayOfMonth: parseInt(e.target.value) }))}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Second payday</label>
<input type="number" min={1} max={31} value={schedule.dayOfMonth2 ?? 15}
onChange={e => setSchedule(s => ({ ...s, dayOfMonth2: parseInt(e.target.value) }))}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900" />
</div>
</div>
)}
{schedule.frequency === 'monthly' && (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Day of month</label>
<input type="number" min={1} max={31} value={schedule.dayOfMonth ?? 1}
onChange={e => setSchedule(s => ({ ...s, dayOfMonth: parseInt(e.target.value) }))}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900" />
</div>
)}
<p className="text-xs text-green-800 font-medium">
Next payday: {computeNextPayday(schedule).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</p>
</div>
)}
</div>
{selectedAccountId && accountEnvelopes.length > 0 && (
+91 -71
View File
@@ -2,97 +2,117 @@
import { useState } from 'react';
interface RecurringTx {
id: string;
description: string;
amount: number;
accountId: string;
envelopeId?: string;
date: Date;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
lastAppliedDate?: string;
}
interface RecurringTransactionManagerProps {
transactions: Array<{
id: string;
description: string;
amount: number;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'monthly' | 'yearly';
lastAppliedDate?: string;
}>;
onApplyRecurring: (transactions: any[]) => void;
transactions: RecurringTx[];
onApplyRecurring: (due: RecurringTx[]) => void;
}
function nextDate(tx: RecurringTx): Date | null {
const last = tx.lastAppliedDate ? new Date(tx.lastAppliedDate) : (tx.date instanceof Date ? tx.date : new Date(tx.date));
const next = new Date(last);
switch (tx.recurringFrequency) {
case 'weekly': next.setDate(next.getDate() + 7); break;
case 'biweekly': next.setDate(next.getDate() + 14); break;
case 'monthly': next.setMonth(next.getMonth() + 1); break;
case 'yearly': next.setFullYear(next.getFullYear() + 1); break;
default: return null;
}
return next;
}
export default function RecurringTransactionManager({ transactions, onApplyRecurring }: RecurringTransactionManagerProps) {
const [expanded, setExpanded] = useState(false);
const getNextRecurringDate = (lastDate: string | undefined, frequency: string | undefined): Date | null => {
if (!lastDate || !frequency) return null;
const last = new Date(lastDate);
const now = new Date();
const recurring = transactions.filter(t => t.isRecurring && t.recurringFrequency);
const now = new Date();
switch (frequency) {
case 'weekly':
const nextWeek = new Date(last);
nextWeek.setDate(nextWeek.getDate() + 7);
return nextWeek <= now ? nextWeek : null;
case 'monthly':
const nextMonth = new Date(last);
nextMonth.setMonth(nextMonth.getMonth() + 1);
return nextMonth <= now ? nextMonth : null;
case 'yearly':
const nextYear = new Date(last);
nextYear.setFullYear(nextYear.getFullYear() + 1);
return nextYear <= now ? nextYear : null;
default:
return null;
}
};
const due = recurring.filter(t => {
const n = nextDate(t);
return n && n <= now;
});
const recurringTransactions = transactions.filter(t => t.isRecurring);
const upcomingTransactions = recurringTransactions.filter(t => getNextRecurringDate(t.lastAppliedDate, t.recurringFrequency));
const upcoming = recurring.filter(t => {
const n = nextDate(t);
return n && n > now;
}).sort((a, b) => {
const na = nextDate(a)!.getTime();
const nb = nextDate(b)!.getTime();
return na - nb;
});
if (recurringTransactions.length === 0) {
return null;
}
if (recurring.length === 0) return null;
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className={`border rounded-lg p-4 mb-6 ${due.length > 0 ? 'bg-amber-50 border-amber-300' : 'bg-blue-50 border-blue-200'}`}>
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex justify-between items-center font-semibold text-blue-900 hover:text-blue-700"
className="w-full flex justify-between items-center font-semibold text-gray-900 hover:text-gray-700"
>
<span>🔄 Recurring Transactions ({recurringTransactions.length})</span>
<span>{expanded ? '▲' : '▼'}</span>
<span className="flex items-center gap-2">
<span>↻ Recurring Transactions</span>
{due.length > 0 && (
<span className="px-2 py-0.5 bg-amber-500 text-white text-xs rounded-full">{due.length} due</span>
)}
</span>
<span className="text-gray-500 text-sm">{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="mt-4 space-y-3">
<p className="text-sm text-blue-800">
You have <strong>{upcomingTransactions.length}</strong> recurring transaction(s) that are due to be applied.
</p>
<div className="mt-4 space-y-4">
{due.length > 0 && (
<div>
<p className="text-sm font-medium text-amber-900 mb-2">Due now ({due.length})</p>
<div className="space-y-2">
{due.map(tx => (
<div key={tx.id} className="bg-white border border-amber-200 rounded-lg p-3 flex justify-between items-center">
<div>
<p className="font-medium text-gray-900 text-sm">{tx.description}</p>
<p className="text-xs text-gray-500 capitalize">{tx.recurringFrequency} · was due {nextDate(tx)?.toLocaleDateString()}</p>
</div>
<p className={`font-semibold text-sm ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{tx.amount >= 0 ? '+' : ''}${Math.abs(tx.amount).toFixed(2)}
</p>
</div>
))}
</div>
<button
onClick={() => onApplyRecurring(due)}
className="w-full mt-3 bg-amber-600 text-white py-2 px-4 rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium"
>
Apply {due.length} overdue transaction{due.length > 1 ? 's' : ''} now
</button>
</div>
)}
{upcomingTransactions.map(tx => (
<div key={tx.id} className="bg-white p-3 rounded border border-blue-100">
<div className="flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{tx.description}</p>
<p className="text-xs text-gray-500">
Frequency: <span className="capitalize font-semibold">{tx.recurringFrequency}</span>
</p>
</div>
<p className={`font-semibold ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${Math.abs(tx.amount).toFixed(2)}
</p>
{upcoming.length > 0 && (
<div>
<p className="text-sm font-medium text-blue-900 mb-2">Upcoming</p>
<div className="space-y-2">
{upcoming.slice(0, 5).map(tx => (
<div key={tx.id} className="bg-white border border-blue-100 rounded-lg p-3 flex justify-between items-center">
<div>
<p className="font-medium text-gray-900 text-sm">{tx.description}</p>
<p className="text-xs text-gray-500 capitalize">{tx.recurringFrequency} · next: {nextDate(tx)?.toLocaleDateString()}</p>
</div>
<p className={`font-semibold text-sm ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{tx.amount >= 0 ? '+' : ''}${Math.abs(tx.amount).toFixed(2)}
</p>
</div>
))}
</div>
</div>
))}
{upcomingTransactions.length > 0 && (
<button
onClick={() => {
const newTransactions = upcomingTransactions.map(tx => ({
...tx,
lastAppliedDate: new Date().toISOString().split('T')[0],
}));
onApplyRecurring(newTransactions);
setExpanded(false);
}}
className="w-full mt-3 bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors"
>
Apply Now
</button>
)}
</div>
)}
+75 -51
View File
@@ -1,61 +1,91 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { AuthService } from '../lib/authService';
interface SharedUser {
shareId: string;
email: string;
role: string;
}
interface SharedAccountManagerProps {
userId: string;
accountId: string;
accountName: string;
}
export default function SharedAccountManager({ userId, accountId, accountName }: SharedAccountManagerProps) {
export default function SharedAccountManager({ accountId, accountName }: SharedAccountManagerProps) {
const [expanded, setExpanded] = useState(false);
const [shareEmail, setShareEmail] = useState('');
const [shareRole, setShareRole] = useState<'view' | 'edit'>('view');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [sharedUsers, setSharedUsers] = useState<Array<{ email: string; role: string }>>([]);
const [sharedUsers, setSharedUsers] = useState<SharedUser[]>([]);
const [loading, setLoading] = useState(false);
const [loadingShares, setLoadingShares] = useState(false);
useEffect(() => {
if (!expanded) return;
setLoadingShares(true);
const token = AuthService.getSessionToken();
fetch('/api/share/list', { headers: { 'x-session-token': token ?? '' } })
.then(r => r.json())
.then(data => {
const forThisAccount: SharedUser[] = (data.owned ?? [])
.filter((s: any) => s.accountId === accountId)
.map((s: any) => ({ shareId: s.shareId, email: s.targetEmail, role: s.role }));
setSharedUsers(forThisAccount);
})
.catch(() => {})
.finally(() => setLoadingShares(false));
}, [expanded, accountId]);
const handleShareAccount = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
setLoading(true);
try {
const token = AuthService.getSessionToken();
const response = await fetch('/api/share/account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': userId,
},
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
body: JSON.stringify({ accountId, email: shareEmail, role: shareRole }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to share account');
}
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Failed to share account');
setMessage({ type: 'success', text: `Account shared with ${shareEmail}` });
setSharedUsers(prev => [...prev, { shareId: result.shareId, email: shareEmail, role: shareRole }]);
setShareEmail('');
setSharedUsers([...sharedUsers, { email: shareEmail, role: shareRole }]);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to share account',
});
setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to share account' });
} finally {
setLoading(false);
}
};
const handleRevoke = async (shareId: string) => {
if (!confirm('Remove this person\'s access?')) return;
try {
const token = AuthService.getSessionToken();
const response = await fetch('/api/share/account', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
body: JSON.stringify({ shareId }),
});
if (!response.ok) throw new Error('Failed to revoke');
setSharedUsers(prev => prev.filter(u => u.shareId !== shareId));
} catch {
setMessage({ type: 'error', text: 'Failed to remove access' });
}
};
return (
<div className="mt-4 pt-4 border-t border-gray-200">
<button
onClick={() => setExpanded(!expanded)}
className="flex justify-between items-center w-full font-semibold text-gray-900 hover:text-gray-700"
className="flex justify-between items-center w-full font-semibold text-gray-900 hover:text-gray-700 text-sm"
>
<span>👥 Share Account</span>
<span>👥 Share "{accountName}"</span>
<span>{expanded ? '▲' : '▼'}</span>
</button>
@@ -63,71 +93,65 @@ export default function SharedAccountManager({ userId, accountId, accountName }:
<div className="mt-4 space-y-4">
<form onSubmit={handleShareAccount} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
<input
type="email"
value={shareEmail}
onChange={(e) => setShareEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 text-sm"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Permission Level
</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Permission</label>
<select
value={shareRole}
onChange={(e) => setShareRole(e.target.value as 'view' | 'edit')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 text-sm"
>
<option value="view">View Only</option>
<option value="edit">Can Edit</option>
</select>
</div>
<button
type="submit"
disabled={loading || !shareEmail}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors text-sm"
>
{loading ? 'Sharing...' : 'Share Account'}
{loading ? 'Sharing…' : 'Share Account'}
</button>
</form>
{message && (
<div
className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
<div className={`p-3 rounded-md text-sm ${message.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
{message.text}
</div>
)}
{sharedUsers.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-900 mb-2">Shared With:</p>
{loadingShares ? (
<p className="text-sm text-gray-400">Loading…</p>
) : sharedUsers.length > 0 ? (
<div>
<p className="text-sm font-medium text-gray-900 mb-2">Shared with:</p>
<ul className="space-y-2">
{sharedUsers.map((user) => (
<li key={user.email} className="flex justify-between items-center text-sm">
<span className="text-gray-700">{user.email}</span>
<span className="text-xs bg-gray-100 px-2 py-1 rounded capitalize">
{user.role}
</span>
{sharedUsers.map((u) => (
<li key={u.shareId} className="flex justify-between items-center text-sm bg-gray-50 px-3 py-2 rounded">
<div>
<span className="text-gray-800">{u.email}</span>
<span className="ml-2 text-xs bg-gray-200 px-1.5 py-0.5 rounded capitalize">{u.role}</span>
</div>
<button onClick={() => handleRevoke(u.shareId)} className="text-xs text-red-600 hover:text-red-800">Remove</button>
</li>
))}
</ul>
</div>
) : (
<p className="text-xs text-gray-500">Not shared with anyone yet.</p>
)}
<p className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
💡 <strong>Note:</strong> Shared accounts will appear in the recipient's Capsule app. They can view or edit based on the permissions you set.
<p className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
The recipient must already have a Capsule account. They'll see this account in their "Shared with me" section.
</p>
</div>
)}
+74 -1
View File
@@ -7,15 +7,27 @@ const DATA_DIR = path.join(process.cwd(), 'data');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
const SHARES_FILE = path.join(DATA_DIR, 'shares.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
interface Share {
ownerUserId: string;
ownerEmail: string;
targetEmail: string;
accountId: string;
role: 'view' | 'edit';
createdAt: string;
}
interface Shares { [shareId: string]: Share }
async function initializeDirectories() {
try {
await fsPromises.mkdir(DATA_DIR, { recursive: true });
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE]) {
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE, SHARES_FILE]) {
try {
await fsPromises.access(file);
} catch {
@@ -268,6 +280,67 @@ export class FileStorageService {
}
}
static async createShare(ownerUserId: string, ownerEmail: string, targetEmail: string, accountId: string, role: 'view' | 'edit'): Promise<string> {
const users = await readJson<Users>(USERS_FILE);
if (!Object.values(users).some(u => u.email === targetEmail)) {
throw new Error('No Capsule account found for that email address');
}
const releaseLock = await FileLock.acquire('shares-file');
try {
const shares = await readJson<Shares>(SHARES_FILE);
// Prevent duplicate shares for same account+email
const existing = Object.entries(shares).find(
([, s]) => s.ownerUserId === ownerUserId && s.accountId === accountId && s.targetEmail === targetEmail
);
if (existing) return existing[0];
const shareId = crypto.randomUUID();
shares[shareId] = { ownerUserId, ownerEmail, targetEmail, accountId, role, createdAt: new Date().toISOString() };
await writeJson(SHARES_FILE, shares);
return shareId;
} finally {
releaseLock();
}
}
static async listShares(email: string, userId: string): Promise<{ received: Array<Share & { shareId: string }>; owned: Array<Share & { shareId: string }> }> {
const shares = await readJson<Shares>(SHARES_FILE);
const received = Object.entries(shares)
.filter(([, s]) => s.targetEmail === email)
.map(([shareId, s]) => ({ shareId, ...s }));
const owned = Object.entries(shares)
.filter(([, s]) => s.ownerUserId === userId)
.map(([shareId, s]) => ({ shareId, ...s }));
return { received, owned };
}
static async getShareData(shareId: string, requestorEmail: string): Promise<{ account: any; envelopes: any[]; transactions: any[] } | null> {
const shares = await readJson<Shares>(SHARES_FILE);
const share = shares[shareId];
if (!share || share.targetEmail !== requestorEmail) return null;
const data = await this.loadUserData(share.ownerUserId);
const account = data.accounts?.find((a: any) => a.id === share.accountId);
if (!account) return null;
const envelopes = (data.envelopes || []).filter((e: any) => e.accountId === share.accountId);
const transactions = (data.transactions || []).filter((t: any) => t.accountId === share.accountId);
return { account, envelopes, transactions };
}
static async revokeShare(shareId: string, requestorUserId: string): Promise<void> {
const releaseLock = await FileLock.acquire('shares-file');
try {
const shares = await readJson<Shares>(SHARES_FILE);
const share = shares[shareId];
if (!share) return;
if (share.ownerUserId !== requestorUserId && share.targetEmail !== requestorUserId) {
throw new Error('Not authorized to revoke this share');
}
delete shares[shareId];
await writeJson(SHARES_FILE, shares);
} finally {
releaseLock();
}
}
private static async _createSession(userId: string, email: string): Promise<string> {
const releaseLock = await FileLock.acquire('sessions-file');
try {