Docs /features/organizations

Organizations

Multi-tenancy and team collaboration

📋 Overview

Organizations enable teams to collaborate on projects with role-based access control. Multiple users can belong to an organization with different permission levels (OWNER, ADMIN, MEMBER).

🎯 User Stories

  • As a team lead, I want to create an organization for my team
  • As an organization owner, I want to invite team members with appropriate roles
  • As a member, I want to access all organization projects
  • As an admin, I want to manage organization settings and members

🏗️ Database Schema

organizations table:

{
  id: string
  name: string
  slug: string       // Unique URL-friendly identifier
  description: string?
  createdAt: Date
  updatedAt: Date
}

organizationMembers table:

{
  id: string
  organizationId: string
  userId: string
  role: 'OWNER' | 'ADMIN' | 'MEMBER'
  createdAt: Date
}

Indexes:

CREATE UNIQUE INDEX organizations_slug_key ON organizations(slug);
CREATE INDEX organization_members_org_user_idx ON organization_members(organization_id, user_id);
CREATE UNIQUE INDEX organization_members_organization_id_user_id_key 
  ON organization_members(organization_id, user_id);

🔌 API Reference

Create Organization

const org = await trpc.organizations.create.mutate({
  name: 'Acme Corp',
  slug: 'acme-corp',
  description: 'Our company organization'
})

// Response
{
  organization: {
    id: 'org_123',
    name: 'Acme Corp',
    slug: 'acme-corp',
    description: 'Our company organization',
    createdAt: '2024-01-01T00:00:00Z'
  }
}

Validation:

  • Name required (min 1 character)
  • Slug must be unique and URL-safe
  • Automatically adds creator as OWNER

List Organizations

// Get user's organizations
const orgs = await trpc.organizations.list.query({
  userId: 'user_123'
})

// Response
{
  organizations: [
    {
      id: 'org_123',
      name: 'Acme Corp',
      slug: 'acme-corp',
      role: 'OWNER',
      memberCount: 5,
      projectCount: 12
    }
  ]
}

Add Member

await trpc.organizations.addMember.mutate({
  organizationId: 'org_123',
  userId: 'user_456',
  role: 'MEMBER'
})

Permissions:

  • Only OWNER and ADMIN can add members
  • Cannot add duplicate members
  • Role options: OWNER, ADMIN, MEMBER

Update Member Role

await trpc.organizations.updateMemberRole.mutate({
  organizationId: 'org_123',
  userId: 'user_456',
  role: 'ADMIN'
})

Rules:

  • Only OWNER can change roles
  • Cannot remove last OWNER
  • Cannot change own role (prevents lockout)

Remove Member

await trpc.organizations.removeMember.mutate({
  organizationId: 'org_123',
  userId: 'user_456'
})

Rules:

  • OWNER and ADMIN can remove members
  • Cannot remove last OWNER
  • Removes access to all organization projects

🎨 Frontend Implementation

Organization Selector

export function OrganizationSelector() {
  const { data } = trpc.organizations.list.useQuery()
  
  return (
    <Select>
      <SelectTrigger>
        <SelectValue placeholder="Select organization" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="personal">Personal</SelectItem>
        {data?.organizations.map((org) => (
          <SelectItem key={org.id} value={org.id}>
            {org.name}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  )
}

Organization Settings Page

export function OrganizationSettings({ orgId }: Props) {
  return (
    <Tabs defaultValue="general">
      <TabsList>
        <TabsTrigger value="general">General</TabsTrigger>
        <TabsTrigger value="members">Members</TabsTrigger>
        <TabsTrigger value="projects">Projects</TabsTrigger>
      </TabsList>

      <TabsContent value="general">
        <OrganizationGeneralSettings orgId={orgId} />
      </TabsContent>

      <TabsContent value="members">
        <MemberManagement orgId={orgId} />
      </TabsContent>

      <TabsContent value="projects">
        <OrganizationProjects orgId={orgId} />
      </TabsContent>
    </Tabs>
  )
}

Member Management

export function MemberManagement({ orgId }: Props) {
  const { data } = trpc.organizations.listMembers.useQuery({ orgId })
  const removeMember = trpc.organizations.removeMember.useMutation()

  return (
    <Card>
      <CardHeader>
        <CardTitle>Members</CardTitle>
      </CardHeader>
      <CardContent>
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Name</TableHead>
              <TableHead>Email</TableHead>
              <TableHead>Role</TableHead>
              <TableHead>Actions</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {data?.members.map((member) => (
              <TableRow key={member.id}>
                <TableCell>{member.name}</TableCell>
                <TableCell>{member.email}</TableCell>
                <TableCell>
                  <Badge>{member.role}</Badge>
                </TableCell>
                <TableCell>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button variant="ghost" size="sm">
                        <MoreVerticalIcon />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent>
                      <DropdownMenuItem>Change Role</DropdownMenuItem>
                      <DropdownMenuItem
                        onClick={() => removeMember.mutate({
                          orgId,
                          userId: member.id
                        })}
                        className="text-destructive"
                      >
                        Remove
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </CardContent>
    </Card>
  )
}

🔐 Permission System

Role Capabilities

Action OWNER ADMIN MEMBER
View projects
Create projects
Delete projects
Add members
Remove members
Change roles
Delete organization

Permission Checks

function canManageMembers(userRole: string): boolean {
  return ['OWNER', 'ADMIN'].includes(userRole)
}

function canDeleteProject(userRole: string): boolean {
  return ['OWNER', 'ADMIN'].includes(userRole)
}

✅ Testing

describe('Organizations', () => {
  it('creates organization with creator as owner', async () => {
    const org = await trpc.organizations.create.mutate({
      name: 'Test Org',
      slug: 'test-org'
    })
    
    const members = await trpc.organizations.listMembers.query({
      orgId: org.id
    })
    
    expect(members[0].role).toBe('OWNER')
  })
})

🔗 Related Documentation


Next: GitHub Integration

Reading this with an agent? /docs/features/organizations.md serves the raw markdown.

All docs