Skip to content

Commit 9b46139

Browse files
Rodrigo Leoteclaude
andcommitted
Add in-app account share invitation accept/decline
Recipients can now accept or decline share invitations directly from the accounts page without needing the email token. Adds ID-based backend endpoints and a pending invitations UI section with Accept/Decline buttons. Also fixes ReceivedShareDto field name mismatch (sharedByUserName → sharedByName). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8749b3e commit 9b46139

9 files changed

Lines changed: 327 additions & 4 deletions

File tree

frontend/messages/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,14 @@
871871
"pendingInvitation": "Pending",
872872
"accepted": "Active",
873873
"declined": "Declined",
874-
"revoked": "Revoked"
874+
"revoked": "Revoked",
875+
"pendingInvitations": "Pending Invitations",
876+
"accept": "Accept",
877+
"decline": "Decline",
878+
"invitationAccepted": "Invitation accepted! The account is now available.",
879+
"invitationDeclined": "Invitation declined.",
880+
"acceptFailed": "Failed to accept invitation. Please try again.",
881+
"declineFailed": "Failed to decline invitation. Please try again."
875882
}
876883
},
877884
"reconciliation": {

frontend/messages/pt-BR.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,14 @@
836836
"pendingInvitation": "Pendente",
837837
"accepted": "Ativo",
838838
"declined": "Recusado",
839-
"revoked": "Revogado"
839+
"revoked": "Revogado",
840+
"pendingInvitations": "Convites Pendentes",
841+
"accept": "Aceitar",
842+
"decline": "Recusar",
843+
"invitationAccepted": "Convite aceito! A conta agora está disponível.",
844+
"invitationDeclined": "Convite recusado.",
845+
"acceptFailed": "Falha ao aceitar convite. Tente novamente.",
846+
"declineFailed": "Falha ao recusar convite. Tente novamente."
840847
},
841848
"createNewAccount": "Criar Nova Conta",
842849
"createNewAccountDesc": "Adicione uma nova conta financeira para acompanhar seu dinheiro",

frontend/src/app/accounts/page.tsx

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
88
import { Button } from '@/components/ui/button';
99
import { formatCurrency } from '@/lib/utils';
1010
import { AccountTypeBadge } from '@/components/ui/account-type-badge';
11-
import { apiClient } from '@/lib/api-client';
11+
import { apiClient, ReceivedShareDto } from '@/lib/api-client';
12+
import { CheckIcon, XMarkIcon, EnvelopeIcon } from '@heroicons/react/24/outline';
1213
import Link from 'next/link';
1314
import { toast } from 'sonner';
1415
import {
@@ -64,6 +65,8 @@ export default function AccountsPage() {
6465
const [archiveConfirm, setArchiveConfirm] = useState<{ show: boolean; account?: Account }>({ show: false });
6566
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; account?: Account }>({ show: false });
6667
const [shareModal, setShareModal] = useState<{ show: boolean; account?: Account }>({ show: false });
68+
const [pendingInvitations, setPendingInvitations] = useState<ReceivedShareDto[]>([]);
69+
const [processingShareIds, setProcessingShareIds] = useState<Set<number>>(new Set());
6770

6871
useEffect(() => {
6972
if (!isLoading && !isAuthenticated) {
@@ -75,6 +78,7 @@ export default function AccountsPage() {
7578
useEffect(() => {
7679
if (isAuthenticated && typeof window !== 'undefined') {
7780
loadAccounts();
81+
loadPendingInvitations();
7882
}
7983
}, [isAuthenticated]);
8084

@@ -97,6 +101,54 @@ export default function AccountsPage() {
97101
}
98102
};
99103

104+
const loadPendingInvitations = async () => {
105+
try {
106+
const shares = await apiClient.getReceivedShares();
107+
setPendingInvitations((shares || []).filter(s => s.status === 1));
108+
} catch (error) {
109+
console.error('Failed to load pending invitations:', error);
110+
}
111+
};
112+
113+
const handleAcceptInvitation = async (shareId: number) => {
114+
if (processingShareIds.has(shareId)) return;
115+
setProcessingShareIds(prev => new Set(prev).add(shareId));
116+
try {
117+
await apiClient.acceptShareById(shareId);
118+
toast.success(t('sharing.invitationAccepted'));
119+
loadAccounts();
120+
loadPendingInvitations();
121+
} catch (error) {
122+
console.error('Failed to accept invitation:', error);
123+
toast.error(t('sharing.acceptFailed'));
124+
} finally {
125+
setProcessingShareIds(prev => {
126+
const next = new Set(prev);
127+
next.delete(shareId);
128+
return next;
129+
});
130+
}
131+
};
132+
133+
const handleDeclineInvitation = async (shareId: number) => {
134+
if (processingShareIds.has(shareId)) return;
135+
setProcessingShareIds(prev => new Set(prev).add(shareId));
136+
try {
137+
await apiClient.declineShareById(shareId);
138+
toast.success(t('sharing.invitationDeclined'));
139+
loadPendingInvitations();
140+
} catch (error) {
141+
console.error('Failed to decline invitation:', error);
142+
toast.error(t('sharing.declineFailed'));
143+
} finally {
144+
setProcessingShareIds(prev => {
145+
const next = new Set(prev);
146+
next.delete(shareId);
147+
return next;
148+
});
149+
}
150+
};
151+
100152
const handleArchiveAccount = async (account: Account) => {
101153
try {
102154
// Archive the account
@@ -218,6 +270,69 @@ export default function AccountsPage() {
218270
</Card>
219271
</div>
220272

273+
{/* Pending Invitations */}
274+
{pendingInvitations.length > 0 && (
275+
<Card className="bg-white/90 backdrop-blur-xs border-0 shadow-lg border-l-4 border-l-amber-400 mb-8">
276+
<CardHeader className="pb-3">
277+
<CardTitle className="flex items-center gap-2 text-lg">
278+
<EnvelopeIcon className="w-5 h-5 text-amber-600" />
279+
{t('sharing.pendingInvitations')}
280+
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-100 text-amber-800 text-xs font-bold">
281+
{pendingInvitations.length}
282+
</span>
283+
</CardTitle>
284+
</CardHeader>
285+
<CardContent className="pt-0">
286+
<div className="space-y-3">
287+
{pendingInvitations.map((invitation) => (
288+
<div
289+
key={invitation.id}
290+
className="flex flex-col sm:flex-row sm:items-center gap-3 p-4 rounded-lg bg-gray-50/50 opacity-75 border border-gray-200"
291+
>
292+
<div className="flex items-center gap-3 min-w-0 flex-1">
293+
<div className="w-10 h-10 sm:w-12 sm:h-12 flex-shrink-0 bg-gradient-to-br from-gray-300 to-gray-500 rounded-xl flex items-center justify-center">
294+
<BuildingOffice2Icon className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
295+
</div>
296+
<div className="min-w-0">
297+
<h4 className="text-base font-semibold text-gray-900 truncate">{invitation.accountName}</h4>
298+
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
299+
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
300+
{t('sharing.pendingInvitation')}
301+
</span>
302+
<span className="text-xs text-gray-500">
303+
{t('sharing.sharedByName', { name: invitation.sharedByName })}
304+
{' - '}
305+
{getShareRoleName(invitation.role)}
306+
</span>
307+
</div>
308+
</div>
309+
</div>
310+
<div className="flex items-center gap-2 pl-13 sm:pl-0">
311+
<Button
312+
size="sm"
313+
onClick={() => handleAcceptInvitation(invitation.id)}
314+
disabled={processingShareIds.has(invitation.id)}
315+
>
316+
<CheckIcon className="w-4 h-4 mr-1" />
317+
{t('sharing.accept')}
318+
</Button>
319+
<Button
320+
variant="outline"
321+
size="sm"
322+
onClick={() => handleDeclineInvitation(invitation.id)}
323+
disabled={processingShareIds.has(invitation.id)}
324+
>
325+
<XMarkIcon className="w-4 h-4 mr-1" />
326+
{t('sharing.decline')}
327+
</Button>
328+
</div>
329+
</div>
330+
))}
331+
</div>
332+
</CardContent>
333+
</Card>
334+
)}
335+
221336
{/* Accounts List */}
222337
<Card className="bg-white/90 backdrop-blur-xs border-0 shadow-lg">
223338
<CardHeader>

frontend/src/lib/api-client.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,14 @@ class ApiClient {
15961596
return this.post('/api/account-shares/decline', { token });
15971597
}
15981598

1599+
async acceptShareById(shareId: number): Promise<AccountShareDto> {
1600+
return this.post(`/api/account-shares/${shareId}/accept`);
1601+
}
1602+
1603+
async declineShareById(shareId: number): Promise<void> {
1604+
return this.post(`/api/account-shares/${shareId}/decline`);
1605+
}
1606+
15991607
// AI Description Cleaning methods
16001608
async previewDescriptionCleaning(descriptions: { rawDescription: string; merchantNameHint?: string }[]): Promise<{
16011609
results: Array<{
@@ -1651,7 +1659,7 @@ export interface ReceivedShareDto {
16511659
id: number;
16521660
accountId: number;
16531661
accountName: string;
1654-
sharedByUserName: string;
1662+
sharedByName: string;
16551663
role: number;
16561664
status: number;
16571665
createdAt: string;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using MediatR;
2+
using MyMascada.Application.Common.Interfaces;
3+
using MyMascada.Application.Features.AccountSharing.DTOs;
4+
using MyMascada.Domain.Common;
5+
using MyMascada.Domain.Enums;
6+
7+
namespace MyMascada.Application.Features.AccountSharing.Commands;
8+
9+
/// <summary>
10+
/// Command to accept a pending account share invitation by share ID (for in-app use).
11+
/// </summary>
12+
public class AcceptAccountShareByIdCommand : IRequest<AccountShareDto>
13+
{
14+
public int ShareId { get; set; }
15+
public Guid UserId { get; set; }
16+
}
17+
18+
public class AcceptAccountShareByIdCommandHandler : IRequestHandler<AcceptAccountShareByIdCommand, AccountShareDto>
19+
{
20+
private readonly IAccountShareRepository _accountShareRepository;
21+
22+
public AcceptAccountShareByIdCommandHandler(IAccountShareRepository accountShareRepository)
23+
{
24+
_accountShareRepository = accountShareRepository;
25+
}
26+
27+
public async Task<AccountShareDto> Handle(AcceptAccountShareByIdCommand request, CancellationToken cancellationToken)
28+
{
29+
var share = await _accountShareRepository.GetByIdAsync(request.ShareId);
30+
if (share == null)
31+
throw new ArgumentException($"Share with ID {request.ShareId} not found.");
32+
33+
// Validate the share belongs to this user
34+
if (share.SharedWithUserId != request.UserId)
35+
throw new UnauthorizedAccessException("This invitation was not sent to you.");
36+
37+
// Validate not expired
38+
if (share.InvitationExpiresAt.HasValue && share.InvitationExpiresAt.Value < DateTimeProvider.UtcNow)
39+
throw new InvalidOperationException("This invitation has expired.");
40+
41+
// Validate status is Pending
42+
if (share.Status != AccountShareStatus.Pending)
43+
throw new InvalidOperationException($"This invitation cannot be accepted because its status is '{share.Status}'.");
44+
45+
// Accept the share
46+
share.Status = AccountShareStatus.Accepted;
47+
share.InvitationToken = null;
48+
share.UpdatedAt = DateTimeProvider.UtcNow;
49+
50+
await _accountShareRepository.UpdateAsync(share);
51+
52+
return new AccountShareDto
53+
{
54+
Id = share.Id,
55+
AccountId = share.AccountId,
56+
AccountName = share.Account.Name,
57+
SharedWithUserId = share.SharedWithUserId,
58+
SharedWithUserEmail = share.SharedWithUser.Email,
59+
SharedWithUserName = share.SharedWithUser.FullName,
60+
Role = share.Role,
61+
Status = share.Status,
62+
CreatedAt = share.CreatedAt
63+
};
64+
}
65+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using MediatR;
2+
using MyMascada.Application.Common.Interfaces;
3+
using MyMascada.Domain.Common;
4+
using MyMascada.Domain.Enums;
5+
6+
namespace MyMascada.Application.Features.AccountSharing.Commands;
7+
8+
/// <summary>
9+
/// Command to decline a pending account share invitation by share ID (for in-app use).
10+
/// </summary>
11+
public class DeclineAccountShareByIdCommand : IRequest<Unit>
12+
{
13+
public int ShareId { get; set; }
14+
public Guid UserId { get; set; }
15+
}
16+
17+
public class DeclineAccountShareByIdCommandHandler : IRequestHandler<DeclineAccountShareByIdCommand, Unit>
18+
{
19+
private readonly IAccountShareRepository _accountShareRepository;
20+
21+
public DeclineAccountShareByIdCommandHandler(IAccountShareRepository accountShareRepository)
22+
{
23+
_accountShareRepository = accountShareRepository;
24+
}
25+
26+
public async Task<Unit> Handle(DeclineAccountShareByIdCommand request, CancellationToken cancellationToken)
27+
{
28+
var share = await _accountShareRepository.GetByIdAsync(request.ShareId);
29+
if (share == null)
30+
throw new ArgumentException($"Share with ID {request.ShareId} not found.");
31+
32+
// Validate the share belongs to this user
33+
if (share.SharedWithUserId != request.UserId)
34+
throw new UnauthorizedAccessException("This invitation was not sent to you.");
35+
36+
// Validate not expired
37+
if (share.InvitationExpiresAt.HasValue && share.InvitationExpiresAt.Value < DateTimeProvider.UtcNow)
38+
throw new InvalidOperationException("This invitation has expired.");
39+
40+
// Validate status is Pending
41+
if (share.Status != AccountShareStatus.Pending)
42+
throw new InvalidOperationException($"This invitation cannot be declined because its status is '{share.Status}'.");
43+
44+
// Decline the share
45+
share.Status = AccountShareStatus.Declined;
46+
share.InvitationToken = null;
47+
share.UpdatedAt = DateTimeProvider.UtcNow;
48+
49+
await _accountShareRepository.UpdateAsync(share);
50+
51+
return Unit.Value;
52+
}
53+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using FluentValidation;
2+
using MyMascada.Application.Features.AccountSharing.Commands;
3+
4+
namespace MyMascada.Application.Features.AccountSharing.Validators;
5+
6+
public class AcceptAccountShareByIdCommandValidator : AbstractValidator<AcceptAccountShareByIdCommand>
7+
{
8+
public AcceptAccountShareByIdCommandValidator()
9+
{
10+
RuleFor(x => x.UserId)
11+
.NotEmpty()
12+
.WithMessage("User ID is required");
13+
14+
RuleFor(x => x.ShareId)
15+
.GreaterThan(0)
16+
.WithMessage("Share ID must be greater than 0");
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using FluentValidation;
2+
using MyMascada.Application.Features.AccountSharing.Commands;
3+
4+
namespace MyMascada.Application.Features.AccountSharing.Validators;
5+
6+
public class DeclineAccountShareByIdCommandValidator : AbstractValidator<DeclineAccountShareByIdCommand>
7+
{
8+
public DeclineAccountShareByIdCommandValidator()
9+
{
10+
RuleFor(x => x.UserId)
11+
.NotEmpty()
12+
.WithMessage("User ID is required");
13+
14+
RuleFor(x => x.ShareId)
15+
.GreaterThan(0)
16+
.WithMessage("Share ID must be greater than 0");
17+
}
18+
}

0 commit comments

Comments
 (0)