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
+518
-161
No files matched your search
@@ -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
@@ -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 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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user