diff --git a/apps/studio/src/hooks/useOrganizationMembers.ts b/apps/studio/src/hooks/useOrganizationMembers.ts new file mode 100644 index 000000000..c8b759350 --- /dev/null +++ b/apps/studio/src/hooks/useOrganizationMembers.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * React hooks for organization member and invitation management. + * Built on top of better-auth's organization plugin APIs. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useClient } from '@objectstack/client-react'; + +export interface OrganizationMember { + id: string; + userId: string; + organizationId: string; + role: string; + createdAt?: string; + user?: { + id: string; + name?: string; + email?: string; + image?: string; + }; +} + +export interface OrganizationInvitation { + id: string; + email: string; + organizationId: string; + role: string; + status: 'pending' | 'accepted' | 'rejected' | 'expired' | 'canceled'; + inviterId: string; + expiresAt: string; + createdAt: string; +} + +/** + * Hook to manage members of an organization + */ +export function useOrganizationMembers(organizationId: string | undefined) { + const client = useClient() as any; + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadMembers = useCallback(async () => { + if (!organizationId || !client?.organizations) return; + + setLoading(true); + setError(null); + try { + const res = await client.organizations.listMembers(organizationId); + const membersList = res?.members ?? res?.data?.members ?? res ?? []; + setMembers(membersList); + } catch (err) { + setError(err as Error); + setMembers([]); + } finally { + setLoading(false); + } + }, [client, organizationId]); + + useEffect(() => { + loadMembers(); + }, [loadMembers]); + + const inviteMember = useCallback( + async (email: string, role: string = 'member') => { + if (!organizationId || !client?.organizations) { + throw new Error('Organization ID or client not available'); + } + + const res = await client.organizations.invite({ + email, + role, + organizationId, + }); + + // Reload members after invitation + await loadMembers(); + return res; + }, + [client, organizationId, loadMembers] + ); + + const removeMember = useCallback( + async (userId: string) => { + if (!organizationId || !client?.organizations) { + throw new Error('Organization ID or client not available'); + } + + // Note: better-auth's organization plugin may not have a direct remove member endpoint + // This would typically be done through the data API or a custom endpoint + // For now, we'll use a placeholder that would need to be implemented + const route = '/api/v1/auth'; + const res = await client.fetch(`${client.baseUrl}${route}/organization/remove-member`, { + method: 'POST', + body: JSON.stringify({ organizationId, userId }), + }); + + if (!res.ok) { + throw new Error('Failed to remove member'); + } + + // Reload members after removal + await loadMembers(); + return res.json(); + }, + [client, organizationId, loadMembers] + ); + + const updateMemberRole = useCallback( + async (userId: string, newRole: string) => { + if (!organizationId || !client?.organizations) { + throw new Error('Organization ID or client not available'); + } + + // Note: Role update would need to be implemented via better-auth or custom endpoint + const route = '/api/v1/auth'; + const res = await client.fetch(`${client.baseUrl}${route}/organization/update-member-role`, { + method: 'POST', + body: JSON.stringify({ organizationId, userId, role: newRole }), + }); + + if (!res.ok) { + throw new Error('Failed to update member role'); + } + + // Reload members after update + await loadMembers(); + return res.json(); + }, + [client, organizationId, loadMembers] + ); + + return { + members, + loading, + error, + reload: loadMembers, + inviteMember, + removeMember, + updateMemberRole, + }; +} + +/** + * Hook to manage organization invitations + */ +export function useOrganizationInvitations(organizationId: string | undefined) { + const client = useClient() as any; + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadInvitations = useCallback(async () => { + if (!organizationId || !client?.organizations) return; + + setLoading(true); + setError(null); + try { + // Note: better-auth may not have a direct list invitations endpoint + // This would need to query the sys_invitation object via data API + const route = '/api/v1/data'; + const res = await client.fetch( + `${client.baseUrl}${route}/sys__invitation?filter=organization_id eq '${organizationId}'&sort=-created_at` + ); + + if (!res.ok) { + throw new Error('Failed to load invitations'); + } + + const data = await res.json(); + const invitationsList = data?.data?.items ?? data?.items ?? []; + setInvitations(invitationsList); + } catch (err) { + setError(err as Error); + setInvitations([]); + } finally { + setLoading(false); + } + }, [client, organizationId]); + + useEffect(() => { + loadInvitations(); + }, [loadInvitations]); + + const cancelInvitation = useCallback( + async (invitationId: string) => { + if (!client) { + throw new Error('Client not available'); + } + + // Update invitation status to 'canceled' + const route = '/api/v1/data'; + const res = await client.fetch(`${client.baseUrl}${route}/sys__invitation/${invitationId}`, { + method: 'PATCH', + body: JSON.stringify({ status: 'canceled' }), + }); + + if (!res.ok) { + throw new Error('Failed to cancel invitation'); + } + + // Reload invitations after cancellation + await loadInvitations(); + return res.json(); + }, + [client, loadInvitations] + ); + + const resendInvitation = useCallback( + async (invitationId: string) => { + if (!client) { + throw new Error('Client not available'); + } + + // This would typically create a new invitation with the same email/role + // and cancel the old one + const route = '/api/v1/auth'; + const res = await client.fetch(`${client.baseUrl}${route}/organization/resend-invitation`, { + method: 'POST', + body: JSON.stringify({ invitationId }), + }); + + if (!res.ok) { + throw new Error('Failed to resend invitation'); + } + + // Reload invitations after resending + await loadInvitations(); + return res.json(); + }, + [client, loadInvitations] + ); + + return { + invitations, + loading, + error, + reload: loadInvitations, + cancelInvitation, + resendInvitation, + }; +} diff --git a/apps/studio/src/routes/orgs.$orgId.tsx b/apps/studio/src/routes/orgs.$orgId.tsx index 9f58a99c5..8b1fdc0b4 100644 --- a/apps/studio/src/routes/orgs.$orgId.tsx +++ b/apps/studio/src/routes/orgs.$orgId.tsx @@ -1,13 +1,41 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; -import { ArrowLeft } from 'lucide-react'; -import { useClient } from '@objectstack/client-react'; +import { useState } from 'react'; +import { ArrowLeft, Clock, Mail, MoreVertical, Trash2, UserPlus, Users, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; import { toast } from '@/hooks/use-toast'; import { useOrganizations, useSession } from '@/hooks/useSession'; +import { + useOrganizationMembers, + useOrganizationInvitations, +} from '@/hooks/useOrganizationMembers'; export const Route = createFileRoute('/orgs/$orgId')({ component: OrgDetailPage, @@ -16,33 +44,27 @@ export const Route = createFileRoute('/orgs/$orgId')({ function OrgDetailPage() { const { orgId } = Route.useParams(); const navigate = useNavigate(); - const client = useClient() as any; const { organizations } = useOrganizations(); const { session, setActiveOrganization } = useSession(); const org = organizations.find((o) => o.id === orgId); - const [members, setMembers] = useState([]); - const [loadingMembers, setLoadingMembers] = useState(false); - - useEffect(() => { - let cancelled = false; - async function loadMembers() { - if (!client?.organizations) return; - setLoadingMembers(true); - try { - const res = await client.organizations.listMembers(orgId); - if (cancelled) return; - setMembers(res?.members ?? res?.data?.members ?? res ?? []); - } catch { - if (!cancelled) setMembers([]); - } finally { - if (!cancelled) setLoadingMembers(false); - } - } - loadMembers(); - return () => { - cancelled = true; - }; - }, [client, orgId]); + + const { + members, + loading: loadingMembers, + inviteMember, + removeMember, + } = useOrganizationMembers(orgId); + + const { + invitations, + loading: loadingInvitations, + cancelInvitation, + } = useOrganizationInvitations(orgId); + + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState('member'); + const [inviting, setInviting] = useState(false); const handleSetActive = async () => { try { @@ -57,12 +79,75 @@ function OrgDetailPage() { } }; + const handleInviteMember = async () => { + if (!inviteEmail) { + toast({ + title: 'Email required', + description: 'Please enter an email address', + variant: 'destructive', + }); + return; + } + + setInviting(true); + try { + await inviteMember(inviteEmail, inviteRole); + toast({ title: 'Invitation sent successfully' }); + setInviteDialogOpen(false); + setInviteEmail(''); + setInviteRole('member'); + } catch (err) { + toast({ + title: 'Failed to send invitation', + description: (err as Error).message, + variant: 'destructive', + }); + } finally { + setInviting(false); + } + }; + + const handleRemoveMember = async (userId: string, memberName: string) => { + if (!confirm(`Are you sure you want to remove ${memberName} from this organization?`)) { + return; + } + + try { + await removeMember(userId); + toast({ title: 'Member removed successfully' }); + } catch (err) { + toast({ + title: 'Failed to remove member', + description: (err as Error).message, + variant: 'destructive', + }); + } + }; + + const handleCancelInvitation = async (invitationId: string, email: string) => { + if (!confirm(`Cancel invitation for ${email}?`)) { + return; + } + + try { + await cancelInvitation(invitationId); + toast({ title: 'Invitation canceled' }); + } catch (err) { + toast({ + title: 'Failed to cancel invitation', + description: (err as Error).message, + variant: 'destructive', + }); + } + }; + const isActive = session?.activeOrganizationId === orgId; + const pendingInvitations = invitations.filter((i) => i.status === 'pending'); return (
-
+
)}
+ + {/* Organization Overview Card */} - {org?.name ?? 'Organization'} - {org?.slug && ( - {org.slug} - )} +
+
+ {org?.name ?? 'Organization'} + {org?.slug && ( + + {org.slug} + + )} +
+ {isActive && ( + + Active + + )} +
@@ -86,36 +184,188 @@ function OrgDetailPage() { {orgId}
- Status - {isActive ? 'Active' : 'Inactive'} + Members + {members.length} +
+
+ Pending Invitations + {pendingInvitations.length}
- - - Members - People with access to this organization. - - - {loadingMembers &&

Loading…

} - {!loadingMembers && members.length === 0 && ( -

No members found.

- )} -
    - {members.map((m, i) => ( -
  • + + {/* Tabs for Members and Invitations */} + + + + + Members ({members.length}) + + + + Invitations ({pendingInvitations.length}) + + + + {/* Members Tab */} + + + +
    -
    {m.user?.name || m.name || m.userId}
    - {m.user?.email && ( -
    {m.user.email}
    - )} + Members + + People with access to this organization +
    - {m.role ?? 'member'} -
  • - ))} -
-
-
+ +
+ + + {loadingMembers && ( +

Loading members...

+ )} + {!loadingMembers && members.length === 0 && ( +
+ +

+ No members yet. Invite someone to get started. +

+ +
+ )} + {!loadingMembers && members.length > 0 && ( +
+ {members.map((m) => ( +
+
+
+ {m.user?.name || m.userId} +
+ {m.user?.email && ( +
+ {m.user.email} +
+ )} +
+
+ + {m.role ?? 'member'} + + {m.role !== 'owner' && ( + + + + + + + handleRemoveMember( + m.userId, + m.user?.name || m.user?.email || m.userId + ) + } + > + + Remove member + + + + )} +
+
+ ))} +
+ )} +
+ + + + {/* Invitations Tab */} + + + +
+
+ Pending Invitations + + Invitations sent to join this organization + +
+ +
+
+ + {loadingInvitations && ( +

Loading invitations...

+ )} + {!loadingInvitations && pendingInvitations.length === 0 && ( +
+ +

+ No pending invitations +

+

+ Invitations will appear here once sent +

+
+ )} + {!loadingInvitations && pendingInvitations.length > 0 && ( +
+ {pendingInvitations.map((inv) => ( +
+
+
{inv.email}
+
+ + {inv.role} + + + + Expires {new Date(inv.expiresAt).toLocaleDateString()} + +
+
+ +
+ ))} +
+ )} +
+
+
+ +

Need to manage environments?{' '} @@ -124,6 +374,61 @@ function OrgDetailPage() {

+ + {/* Invite Member Dialog */} + + + + Invite member + + Send an invitation to join {org?.name} + + +
+
+ + setInviteEmail(e.target.value)} + /> +
+
+ + +

+ {inviteRole === 'owner' && 'Full access to manage the organization'} + {inviteRole === 'admin' && + 'Can manage members and settings, but cannot delete the organization'} + {inviteRole === 'member' && 'Can view and use organization resources'} +

+
+
+ + + + +
+
); }