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:
1 parent
18fe6b5141
commit
5339e9818a
9 files changed
+508
-151
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -211,6 +213,8 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SharedAccountManager accountId={account.id} accountName={account.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+97
-11
@@ -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 && (
|
||||
|
||||
@@ -2,97 +2,117 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface RecurringTransactionManagerProps {
|
||||
transactions: Array<{
|
||||
interface RecurringTx {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
accountId: string;
|
||||
envelopeId?: string;
|
||||
date: Date;
|
||||
isRecurring?: boolean;
|
||||
recurringFrequency?: 'weekly' | 'monthly' | 'yearly';
|
||||
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
|
||||
lastAppliedDate?: string;
|
||||
}>;
|
||||
onApplyRecurring: (transactions: any[]) => void;
|
||||
}
|
||||
|
||||
interface RecurringTransactionManagerProps {
|
||||
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 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>
|
||||
|
||||
{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 className="mt-4 space-y-4">
|
||||
{due.length > 0 && (
|
||||
<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>
|
||||
<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 ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${Math.abs(tx.amount).toFixed(2)}
|
||||
<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>
|
||||
))}
|
||||
|
||||
{upcomingTransactions.length > 0 && (
|
||||
</div>
|
||||
<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"
|
||||
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 Now
|
||||
Apply {due.length} overdue transaction{due.length > 1 ? 's' : ''} now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user