Skip to content

Commit 463fa07

Browse files
authored
Merge pull request #1205 from objectstack-ai/claude/design-organization-management-features
feat: add organization member and invitation management UI
2 parents 6f4d54f + 1eba66f commit 463fa07

2 files changed

Lines changed: 606 additions & 57 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* React hooks for organization member and invitation management.
5+
* Built on top of better-auth's organization plugin APIs.
6+
*/
7+
8+
import { useCallback, useEffect, useState } from 'react';
9+
import { useClient } from '@objectstack/client-react';
10+
11+
export interface OrganizationMember {
12+
id: string;
13+
userId: string;
14+
organizationId: string;
15+
role: string;
16+
createdAt?: string;
17+
user?: {
18+
id: string;
19+
name?: string;
20+
email?: string;
21+
image?: string;
22+
};
23+
}
24+
25+
export interface OrganizationInvitation {
26+
id: string;
27+
email: string;
28+
organizationId: string;
29+
role: string;
30+
status: 'pending' | 'accepted' | 'rejected' | 'expired' | 'canceled';
31+
inviterId: string;
32+
expiresAt: string;
33+
createdAt: string;
34+
}
35+
36+
/**
37+
* Hook to manage members of an organization
38+
*/
39+
export function useOrganizationMembers(organizationId: string | undefined) {
40+
const client = useClient() as any;
41+
const [members, setMembers] = useState<OrganizationMember[]>([]);
42+
const [loading, setLoading] = useState(false);
43+
const [error, setError] = useState<Error | null>(null);
44+
45+
const loadMembers = useCallback(async () => {
46+
if (!organizationId || !client?.organizations) return;
47+
48+
setLoading(true);
49+
setError(null);
50+
try {
51+
const res = await client.organizations.listMembers(organizationId);
52+
const membersList = res?.members ?? res?.data?.members ?? res ?? [];
53+
setMembers(membersList);
54+
} catch (err) {
55+
setError(err as Error);
56+
setMembers([]);
57+
} finally {
58+
setLoading(false);
59+
}
60+
}, [client, organizationId]);
61+
62+
useEffect(() => {
63+
loadMembers();
64+
}, [loadMembers]);
65+
66+
const inviteMember = useCallback(
67+
async (email: string, role: string = 'member') => {
68+
if (!organizationId || !client?.organizations) {
69+
throw new Error('Organization ID or client not available');
70+
}
71+
72+
const res = await client.organizations.invite({
73+
email,
74+
role,
75+
organizationId,
76+
});
77+
78+
// Reload members after invitation
79+
await loadMembers();
80+
return res;
81+
},
82+
[client, organizationId, loadMembers]
83+
);
84+
85+
const removeMember = useCallback(
86+
async (userId: string) => {
87+
if (!organizationId || !client?.organizations) {
88+
throw new Error('Organization ID or client not available');
89+
}
90+
91+
// Note: better-auth's organization plugin may not have a direct remove member endpoint
92+
// This would typically be done through the data API or a custom endpoint
93+
// For now, we'll use a placeholder that would need to be implemented
94+
const route = '/api/v1/auth';
95+
const res = await client.fetch(`${client.baseUrl}${route}/organization/remove-member`, {
96+
method: 'POST',
97+
body: JSON.stringify({ organizationId, userId }),
98+
});
99+
100+
if (!res.ok) {
101+
throw new Error('Failed to remove member');
102+
}
103+
104+
// Reload members after removal
105+
await loadMembers();
106+
return res.json();
107+
},
108+
[client, organizationId, loadMembers]
109+
);
110+
111+
const updateMemberRole = useCallback(
112+
async (userId: string, newRole: string) => {
113+
if (!organizationId || !client?.organizations) {
114+
throw new Error('Organization ID or client not available');
115+
}
116+
117+
// Note: Role update would need to be implemented via better-auth or custom endpoint
118+
const route = '/api/v1/auth';
119+
const res = await client.fetch(`${client.baseUrl}${route}/organization/update-member-role`, {
120+
method: 'POST',
121+
body: JSON.stringify({ organizationId, userId, role: newRole }),
122+
});
123+
124+
if (!res.ok) {
125+
throw new Error('Failed to update member role');
126+
}
127+
128+
// Reload members after update
129+
await loadMembers();
130+
return res.json();
131+
},
132+
[client, organizationId, loadMembers]
133+
);
134+
135+
return {
136+
members,
137+
loading,
138+
error,
139+
reload: loadMembers,
140+
inviteMember,
141+
removeMember,
142+
updateMemberRole,
143+
};
144+
}
145+
146+
/**
147+
* Hook to manage organization invitations
148+
*/
149+
export function useOrganizationInvitations(organizationId: string | undefined) {
150+
const client = useClient() as any;
151+
const [invitations, setInvitations] = useState<OrganizationInvitation[]>([]);
152+
const [loading, setLoading] = useState(false);
153+
const [error, setError] = useState<Error | null>(null);
154+
155+
const loadInvitations = useCallback(async () => {
156+
if (!organizationId || !client?.organizations) return;
157+
158+
setLoading(true);
159+
setError(null);
160+
try {
161+
// Note: better-auth may not have a direct list invitations endpoint
162+
// This would need to query the sys_invitation object via data API
163+
const route = '/api/v1/data';
164+
const res = await client.fetch(
165+
`${client.baseUrl}${route}/sys__invitation?filter=organization_id eq '${organizationId}'&sort=-created_at`
166+
);
167+
168+
if (!res.ok) {
169+
throw new Error('Failed to load invitations');
170+
}
171+
172+
const data = await res.json();
173+
const invitationsList = data?.data?.items ?? data?.items ?? [];
174+
setInvitations(invitationsList);
175+
} catch (err) {
176+
setError(err as Error);
177+
setInvitations([]);
178+
} finally {
179+
setLoading(false);
180+
}
181+
}, [client, organizationId]);
182+
183+
useEffect(() => {
184+
loadInvitations();
185+
}, [loadInvitations]);
186+
187+
const cancelInvitation = useCallback(
188+
async (invitationId: string) => {
189+
if (!client) {
190+
throw new Error('Client not available');
191+
}
192+
193+
// Update invitation status to 'canceled'
194+
const route = '/api/v1/data';
195+
const res = await client.fetch(`${client.baseUrl}${route}/sys__invitation/${invitationId}`, {
196+
method: 'PATCH',
197+
body: JSON.stringify({ status: 'canceled' }),
198+
});
199+
200+
if (!res.ok) {
201+
throw new Error('Failed to cancel invitation');
202+
}
203+
204+
// Reload invitations after cancellation
205+
await loadInvitations();
206+
return res.json();
207+
},
208+
[client, loadInvitations]
209+
);
210+
211+
const resendInvitation = useCallback(
212+
async (invitationId: string) => {
213+
if (!client) {
214+
throw new Error('Client not available');
215+
}
216+
217+
// This would typically create a new invitation with the same email/role
218+
// and cancel the old one
219+
const route = '/api/v1/auth';
220+
const res = await client.fetch(`${client.baseUrl}${route}/organization/resend-invitation`, {
221+
method: 'POST',
222+
body: JSON.stringify({ invitationId }),
223+
});
224+
225+
if (!res.ok) {
226+
throw new Error('Failed to resend invitation');
227+
}
228+
229+
// Reload invitations after resending
230+
await loadInvitations();
231+
return res.json();
232+
},
233+
[client, loadInvitations]
234+
);
235+
236+
return {
237+
invitations,
238+
loading,
239+
error,
240+
reload: loadInvitations,
241+
cancelInvitation,
242+
resendInvitation,
243+
};
244+
}

0 commit comments

Comments
 (0)