Appearance
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 = defaultQueryKeysThis 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>
)
}