Skip to content

Commit 5803e2b

Browse files
committed
feat: enhance MembersTab with Better Auth integration
- Added functionality to manage member invitations, including fetching and displaying pending invitations. - Implemented user role checks to restrict member removal and invitation cancellation to organization owners. - Updated invite handling to refresh the invitations list after sending new invites. - Enhanced organization context to support asynchronous ownership checks. - Improved error handling and user feedback with toast notifications for various actions.
1 parent d0cd92e commit 5803e2b

8 files changed

Lines changed: 655 additions & 15 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { useParams, useRouter } from 'next/navigation';
5+
import { authClient } from '@/lib/auth-client';
6+
import { toast } from 'sonner';
7+
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
8+
9+
export default function AcceptInvitationPage() {
10+
const params = useParams();
11+
const router = useRouter();
12+
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
13+
'loading'
14+
);
15+
const [errorMessage, setErrorMessage] = useState<string>('');
16+
const invitationId = params.invitationId as string;
17+
18+
useEffect(() => {
19+
const acceptInvitation = async () => {
20+
if (!invitationId) {
21+
setStatus('error');
22+
setErrorMessage('No invitation ID provided');
23+
return;
24+
}
25+
26+
try {
27+
const result = await authClient.organization.acceptInvitation({
28+
invitationId,
29+
});
30+
31+
if (result.data) {
32+
// Extract organizationId from the response structure
33+
// Better Auth returns: { invitation: {...}, member: {...} }
34+
const response = result.data as {
35+
invitation?: { organizationId: string };
36+
member?: { organizationId: string };
37+
};
38+
39+
const organizationId =
40+
response.invitation?.organizationId ||
41+
response.member?.organizationId;
42+
43+
if (!organizationId) {
44+
throw new Error('Organization ID not found in response');
45+
}
46+
47+
toast.success('Invitation accepted! Redirecting...');
48+
setStatus('success');
49+
50+
setTimeout(() => {
51+
router.push(`/organizations/${organizationId}`);
52+
}, 1500);
53+
} else {
54+
throw new Error('Failed to accept invitation');
55+
}
56+
} catch (error) {
57+
console.error('Error accepting invitation:', error);
58+
const message =
59+
error instanceof Error
60+
? error.message
61+
: 'Failed to accept invitation. It may be expired or invalid.';
62+
setErrorMessage(message);
63+
toast.error(message);
64+
setStatus('error');
65+
}
66+
};
67+
68+
acceptInvitation();
69+
}, [invitationId, router]);
70+
71+
return (
72+
<div className='flex min-h-screen items-center justify-center bg-black'>
73+
<div className='w-full max-w-md rounded-xl border border-zinc-800 bg-zinc-900/50 p-8 text-center backdrop-blur-sm'>
74+
{status === 'loading' && (
75+
<div className='space-y-4'>
76+
<Loader2 className='text-primary mx-auto h-12 w-12 animate-spin' />
77+
<h1 className='text-2xl font-semibold text-white'>
78+
Accepting Invitation
79+
</h1>
80+
<p className='text-zinc-400'>
81+
Please wait while we process your invitation...
82+
</p>
83+
</div>
84+
)}
85+
86+
{status === 'success' && (
87+
<div className='space-y-4'>
88+
<CheckCircle2 className='mx-auto h-12 w-12 text-green-500' />
89+
<h1 className='text-2xl font-semibold text-white'>
90+
Invitation Accepted!
91+
</h1>
92+
<p className='text-zinc-400'>
93+
Redirecting you to the organization...
94+
</p>
95+
</div>
96+
)}
97+
98+
{status === 'error' && (
99+
<div className='space-y-4'>
100+
<XCircle className='mx-auto h-12 w-12 text-red-500' />
101+
<h1 className='text-2xl font-semibold text-white'>
102+
Failed to Accept Invitation
103+
</h1>
104+
<p className='text-zinc-400'>
105+
{errorMessage || 'The invitation may be expired or invalid.'}
106+
</p>
107+
<button
108+
onClick={() => router.push('/organizations')}
109+
className='bg-primary hover:bg-primary/90 mt-4 rounded-lg px-6 py-2 text-white transition-colors'
110+
>
111+
Go to Organizations
112+
</button>
113+
</div>
114+
)}
115+
</div>
116+
</div>
117+
);
118+
}

components/organization/tabs/MembersTab.tsx

Lines changed: 208 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
'use client';
22

3-
import { useState, useMemo } from 'react';
3+
import { useState, useMemo, useEffect } from 'react';
44
import { BoundlessButton } from '@/components/buttons';
55
import { useOrganization } from '@/lib/providers/OrganizationProvider';
6+
import {
7+
removeBetterAuthMember,
8+
listBetterAuthInvitations,
9+
cancelBetterAuthInvitation,
10+
} from '@/lib/api/better-auth-organization';
611
import EmailInviteSection from './MembersTab/EmailInviteSection';
712
import PermissionsTable from './MembersTab/PermissionsTable';
813
import TeamManagementSection from './MembersTab/TeamManagementSection';
914
import { toast } from 'sonner';
15+
import { X, Mail, Clock } from 'lucide-react';
1016

1117
interface Member {
1218
id: string;
@@ -18,6 +24,17 @@ interface Member {
1824
status: 'active' | 'pending' | 'suspended';
1925
}
2026

27+
interface Invitation {
28+
id: string;
29+
email: string;
30+
role: string;
31+
status: string;
32+
expiresAt: Date;
33+
createdAt?: Date;
34+
organizationId: string;
35+
inviterId: string;
36+
}
37+
2138
interface MembersTabProps {
2239
onSave?: (members: Member[]) => void;
2340
}
@@ -28,11 +45,22 @@ export default function MembersTab({ onSave }: MembersTabProps) {
2845
activeOrgId,
2946
updateOrganizationMembers,
3047
inviteMember,
31-
removeMember,
3248
assignRole,
3349
isLoading,
50+
isOwner,
51+
refreshOrganization,
3452
} = useOrganization();
3553

54+
const [userIsOwner, setUserIsOwner] = useState(false);
55+
useEffect(() => {
56+
const checkUserIsOwner = async () => {
57+
const checkIsOwner = await isOwner(activeOrgId || undefined);
58+
setUserIsOwner(checkIsOwner);
59+
console.log('userIsOwner', checkIsOwner);
60+
};
61+
checkUserIsOwner();
62+
}, [activeOrgId]);
63+
3664
const members: Member[] = useMemo(() => {
3765
const emails = activeOrg?.members ?? [];
3866
return emails.map((email, idx) => ({
@@ -44,15 +72,53 @@ export default function MembersTab({ onSave }: MembersTabProps) {
4472
status: 'active',
4573
}));
4674
}, [activeOrg?.members]);
75+
4776
const [inviteEmails, setInviteEmails] = useState<string[]>([]);
4877
const [emailInput, setEmailInput] = useState('');
4978
const [hasUserChanges, setHasUserChanges] = useState(false);
79+
const [invitations, setInvitations] = useState<Invitation[]>([]);
80+
const [loadingInvitations, setLoadingInvitations] = useState(false);
81+
const [cancelingInvitation, setCancelingInvitation] = useState<string | null>(
82+
null
83+
);
84+
85+
// Fetch invitations when component mounts or activeOrg changes
86+
useEffect(() => {
87+
const fetchInvitations = async () => {
88+
if (!activeOrg?.betterAuthOrgId) return;
89+
90+
setLoadingInvitations(true);
91+
try {
92+
const data = await listBetterAuthInvitations(activeOrg.betterAuthOrgId);
93+
setInvitations(data || []);
94+
} catch (error) {
95+
console.error('Error fetching invitations:', error);
96+
toast.error('Failed to load invitations');
97+
} finally {
98+
setLoadingInvitations(false);
99+
}
100+
};
101+
102+
fetchInvitations();
103+
}, [activeOrg?.betterAuthOrgId]);
50104

51-
const handleInvite = () => {
105+
const handleInvite = async () => {
52106
if (inviteEmails.length > 0 && activeOrgId) {
53-
inviteMember(activeOrgId, inviteEmails);
107+
await inviteMember(activeOrgId, inviteEmails);
54108
setInviteEmails([]);
55109
setEmailInput('');
110+
111+
// Refresh invitations list after sending new invites
112+
if (activeOrg?.betterAuthOrgId) {
113+
try {
114+
const data = await listBetterAuthInvitations(
115+
activeOrg.betterAuthOrgId
116+
);
117+
setInvitations(data || []);
118+
} catch (error) {
119+
console.error('Error refreshing invitations:', error);
120+
}
121+
}
56122
}
57123
};
58124

@@ -73,11 +139,76 @@ export default function MembersTab({ onSave }: MembersTabProps) {
73139
}
74140
};
75141

76-
const handleRemoveMember = (memberId: string) => {
77-
if (!activeOrgId) return;
78-
const m = members.find(x => x.id === memberId);
79-
if (!m) return;
80-
removeMember(activeOrgId, m.email);
142+
const handleRemoveMember = async (memberId: string) => {
143+
if (!activeOrgId) {
144+
toast.error('No organization selected');
145+
return;
146+
}
147+
148+
// Check if user is owner
149+
const userIsOwner = await isOwner(activeOrgId);
150+
if (!userIsOwner) {
151+
toast.error('Only organization owners can remove members');
152+
return;
153+
}
154+
155+
const member = members.find(x => x.id === memberId);
156+
if (!member) {
157+
toast.error('Member not found');
158+
return;
159+
}
160+
161+
try {
162+
// Check if organization uses Better Auth
163+
if (activeOrg?.betterAuthOrgId) {
164+
// Use Better Auth API
165+
await removeBetterAuthMember(member.email, activeOrg.betterAuthOrgId);
166+
toast.success(`${member.email} has been removed from the organization`);
167+
} else {
168+
// Fallback to custom API for legacy organizations
169+
toast.error(
170+
'This organization does not support Better Auth member removal'
171+
);
172+
return;
173+
}
174+
175+
// Refresh organization data to reflect changes
176+
await refreshOrganization();
177+
setHasUserChanges(true);
178+
} catch (error) {
179+
const msg = error instanceof Error ? error.message : String(error);
180+
toast.error(`Failed to remove member: ${msg}`);
181+
console.error('Error removing member:', error);
182+
}
183+
};
184+
185+
const handleCancelInvitation = async (invitationId: string) => {
186+
if (!activeOrgId) {
187+
toast.error('No organization selected');
188+
return;
189+
}
190+
191+
// Check if user is owner
192+
const userIsOwner = await isOwner(activeOrgId);
193+
if (!userIsOwner) {
194+
toast.error('Only organization owners can cancel invitations');
195+
return;
196+
}
197+
198+
setCancelingInvitation(invitationId);
199+
try {
200+
await cancelBetterAuthInvitation(invitationId);
201+
toast.success('Invitation cancelled successfully');
202+
203+
// Remove invitation from local state
204+
setInvitations(prev => prev.filter(inv => inv.id !== invitationId));
205+
} catch (error) {
206+
const msg = error instanceof Error ? error.message : String(error);
207+
toast.error(`Failed to cancel invitation: ${msg}`);
208+
console.error('Error canceling invitation:', error);
209+
} finally {
210+
setCancelingInvitation(null);
211+
}
81212
};
82213

83214
const handleSave = async () => {
@@ -100,6 +231,74 @@ export default function MembersTab({ onSave }: MembersTabProps) {
100231
onInvite={handleInvite}
101232
/>
102233

234+
{/* Pending Invitations Section */}
235+
{activeOrg?.betterAuthOrgId && (
236+
<div className='space-y-4'>
237+
<div className='flex items-center justify-between'>
238+
<h3 className='text-lg font-semibold text-white'>
239+
Pending Invitations
240+
</h3>
241+
{loadingInvitations && (
242+
<span className='text-sm text-zinc-400'>Loading...</span>
243+
)}
244+
</div>
245+
246+
{invitations.length > 0 ? (
247+
<div className='space-y-2'>
248+
{invitations.map(invitation => (
249+
<div
250+
key={invitation.id}
251+
className='flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900/50 p-4'
252+
>
253+
<div className='flex items-center gap-3'>
254+
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-zinc-800'>
255+
<Mail className='h-5 w-5 text-zinc-400' />
256+
</div>
257+
<div>
258+
<p className='font-medium text-white'>
259+
{invitation.email}
260+
</p>
261+
<div className='flex items-center gap-2 text-sm text-zinc-400'>
262+
<span>Role: {invitation.role}</span>
263+
<span></span>
264+
<div className='flex items-center gap-1'>
265+
<Clock className='h-3 w-3' />
266+
<span>
267+
Expires:{' '}
268+
{new Date(
269+
invitation.expiresAt
270+
).toLocaleDateString()}
271+
</span>
272+
</div>
273+
</div>
274+
</div>
275+
</div>
276+
{userIsOwner}
277+
{userIsOwner && (
278+
<button
279+
onClick={() => handleCancelInvitation(invitation.id)}
280+
disabled={cancelingInvitation === invitation.id}
281+
className='flex items-center gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-400 transition-colors hover:bg-red-500/20 disabled:opacity-50'
282+
title='Cancel invitation'
283+
>
284+
<X className='h-4 w-4' />
285+
{cancelingInvitation === invitation.id
286+
? 'Canceling...'
287+
: 'Cancel'}
288+
</button>
289+
)}
290+
</div>
291+
))}
292+
</div>
293+
) : (
294+
<div className='rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center'>
295+
<Mail className='mx-auto mb-2 h-8 w-8 text-zinc-600' />
296+
<p className='text-sm text-zinc-400'>No pending invitations</p>
297+
</div>
298+
)}
299+
</div>
300+
)}
301+
103302
<PermissionsTable />
104303

105304
<TeamManagementSection

0 commit comments

Comments
 (0)