Skip to content

Organization Management Frontend

Frontend implementation for managing organizations using Better Auth's organization plugin with React Query state management. This is centralized @jubiloop/api-client package.

Overview

Jubiloop supports multi-tenant functionality through Better Auth's organization plugin, now centralized in the @jubiloop/api-client package. Users can create, join, and manage multiple organizations with role-based access control.

Organization State Management

Organizations are managed as auth-dependent state through React Query with their own query scope in @jubiloop/api-client:

typescript
// packages/api-client/src/keys/defaults.ts
defaultQueryKeys.auth.organizations = {
  all: ['api', 'auth', 'organizations'] as const,
  list: () => ['api', 'auth', 'organizations', 'list'] as const,
  active: () => ['api', 'auth', 'organizations', 'active'] as const,
  detail: (id: string) => ['api', 'auth', 'organizations', 'detail', id] as const,
}

// Re-exported in webapp for convenience
// apps/webapp/src/integrations/tanstack-query/keys.ts
import { defaultQueryKeys } from '@jubiloop/api-client/keys/defaults'
export const queryKeys = defaultQueryKeys

This centralization ensures:

  • Consistent cache keys across all apps
  • Organization changes don't invalidate session queries
  • Granular cache invalidation for better performance

Organization Hooks

Listing Organizations

typescript
import { api } from '@/lib/api/setup'

function OrganizationList() {
  // Using centralized hooks from @jubiloop/api-client
  const { data: organizations, isLoading } = api.hooks.organization.useOrganizations()

  if (isLoading) return <Spinner />

  return (
    <div>
      {organizations?.map(org => (
        <OrganizationCard key={org.id} organization={org} />
      ))}
    </div>
  )
}

Active Organization

typescript
import { api } from '@/lib/api/setup'

function CurrentOrgDisplay() {
  // Using centralized hooks from @jubiloop/api-client
  const { data: activeOrg } = api.hooks.organization.useActiveOrganization()

  return (
    <div>
      <h2>Current Organization: {activeOrg?.name}</h2>
      <p>Members: {activeOrg?.members?.length || 0}</p>
    </div>
  )
}

Specific Organization

typescript
import { api } from '@/lib/api/setup'

function OrganizationDetails({ orgId }: { orgId: string }) {
  // Using centralized hooks from @jubiloop/api-client
  const { data: organization, isLoading } = api.hooks.organization.useOrganization(orgId)

  if (isLoading) return <Spinner />
  if (!organization) return <NotFound />

  return (
    <div>
      <h2>{organization.name}</h2>
      <p>Slug: {organization.slug}</p>
      <p>Members: {organization.members?.length || 0}</p>
    </div>
  )
}

Creating Organizations

typescript
import { api } from '@/lib/api/setup'

function CreateOrgForm() {
  // Using centralized hooks from @jubiloop/api-client
  const createOrg = api.hooks.organization.useCreateOrganization()

  const handleSubmit = (data: { name: string; slug?: string }) => {
    createOrg.mutate(data, {
      onSuccess: (newOrg) => {
        // Optionally switch to new org
        // navigate to org dashboard
      }
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Organization Name" required />
      <input name="slug" placeholder="org-slug (optional)" />
      <button type="submit" disabled={createOrg.isPending}>
        Create Organization
      </button>
    </form>
  )
}

Switching Organizations

typescript
import { api } from '@/lib/api/setup'

function OrganizationSwitcher() {
  // All hooks from centralized @jubiloop/api-client
  const { data: organizations } = api.hooks.organization.useOrganizations()
  const { data: activeOrg } = api.hooks.organization.useActiveOrganization()
  const switchOrg = api.hooks.organization.useSwitchOrganization()

  const handleSwitch = (orgId: string) => {
    switchOrg.mutate(orgId, {
      onSuccess: () => {
        // Organization switched
        // All org-dependent queries will refetch
        // Navigate or update UI as needed
      }
    })
  }

  return (
    <Select value={activeOrg?.id} onValueChange={handleSwitch}>
      <SelectTrigger>
        <SelectValue placeholder="Select organization" />
      </SelectTrigger>
      <SelectContent>
        {organizations?.map(org => (
          <SelectItem key={org.id} value={org.id}>
            {org.name}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  )
}

Organization Operations

Updating Organizations

typescript
const updateOrg = useUpdateOrganization()

updateOrg.mutate(
  {
    organizationId: orgId,
    data: {
      name: 'New Name',
      slug: 'new-slug',
    },
  },
  {
    onSuccess: () => {
      toast.success('Organization updated')
    },
  }
)

Deleting Organizations

typescript
const deleteOrg = useDeleteOrganization()

// With confirmation dialog
const handleDelete = () => {
  if (confirm('Are you sure? This cannot be undone.')) {
    deleteOrg.mutate(orgId, {
      onSuccess: () => {
        // Redirect to org list or dashboard
        navigate({ to: '/organizations' })
      },
    })
  }
}

Configuration Options

All organization mutations accept optional configuration:

typescript
// Disable toast notifications
const createOrg = useCreateOrganization({ showToast: false })

// Custom success handling
createOrg.mutate(data, {
  onSuccess: (newOrg) => {
    // Custom notification
    showCustomNotification(`Created ${newOrg.name}`)
  },
})

Query Invalidation

Query invalidation logic is centralized in the @jubiloop/api-client package hooks:

typescript
// The invalidation logic is handled internally by the api-client hooks
// Example from packages/api-client/src/hooks/organization.ts:

// Create: Only invalidates list
queryClient.invalidateQueries({
  queryKey: defaultQueryKeys.auth.organizations.list(),
  exact: true,
})

// Switch: Directly sets active organization data
queryClient.setQueryData(defaultQueryKeys.auth.organizations.active(), data)

// Update: Invalidates list and specific detail
queryClient.invalidateQueries({
  queryKey: defaultQueryKeys.auth.organizations.list(),
})
queryClient.invalidateQueries({
  queryKey: defaultQueryKeys.auth.organizations.detail(orgId),
})

This centralized approach:

  • Ensures consistent cache management across all apps
  • Provides instant UI updates
  • Maintains cache consistency

Permissions and Roles

Organizations support role-based access control:

  • Owner: Full control, can delete organization
  • Admin: Manage members, settings, cannot delete
  • Member: Basic access, view-only permissions
typescript
// Check user role in active organization
const { data: activeOrg } = useActiveOrganization()
const userRole = activeOrg?.members?.find((m) => m.userId === user.id)?.role

const canManageOrg = ['owner', 'admin'].includes(userRole)

Member Management

Inviting Members

typescript
import { useInviteOrganizationMember } from '@/hooks/auth/use-organization'
import type { TOrganizationMemberRole } from '@/types/organization'

function InviteMemberForm({ organizationId }: { organizationId: string }) {
  const inviteMember = useInviteOrganizationMember(organizationId)

  const handleInvite = (data: { email: string; role: string }) => {
    inviteMember.mutate(data, {
      onSuccess: () => {
        // Handle success - maybe close modal
      }
    })
  }

  return (
    <form onSubmit={handleInvite}>
      <input name="email" type="email" placeholder="user@example.com" required />
      <select name="role">
        <option value="member">Member</option>
        <option value="admin">Admin</option>
      </select>
      <button type="submit" disabled={inviteMember.isPending}>
        Send Invitation
      </button>
    </form>
  )
}

Removing Members

typescript
import { useRemoveOrganizationMember, useOrganization, useActiveOrganization } from '@/hooks/auth/use-organization'

function MemberList({ organizationId }: { organizationId: string }) {
  const removeMember = useRemoveOrganizationMember(organizationId)
  const { data: org } = useOrganization(organizationId)

  const handleRemove = (memberId: string) => {
    if (confirm('Remove this member?')) {
      removeMember.mutate(memberId)
    }
  }

  return (
    <div>
      {org?.members?.map(member => (
        <div key={member.id}>
          <span>{member.user.name} ({member.role})</span>
          <button onClick={() => handleRemove(member.id)}>
            Remove
          </button>
        </div>
      ))}
    </div>
  )
}

// Alternative: For active organization members
function ActiveOrgMemberList() {
  const removeMember = useRemoveOrganizationMember() // Uses active org
  const { data: activeOrg } = useActiveOrganization()

  const handleRemove = (memberId: string) => {
    if (confirm('Remove this member?')) {
      removeMember.mutate(memberId)
    }
  }

  return (
    <div>
      {activeOrg?.members?.map(member => (
        <div key={member.id}>
          <span>{member.user.name} ({member.role})</span>
          <button onClick={() => handleRemove(member.id)}>
            Remove
          </button>
        </div>
      ))}
    </div>
  )
}

Built with ❤️ by the Jubiloop team