Team Management

Roles & Permissions

Team role-based access control.

Nuda Kit uses AdonisJS Bouncer for authorization. The team system implements a hierarchical role-based access control (RBAC) where higher roles can manage lower roles.

Role Hierarchy

Teams use five permission levels, ordered from highest to lowest:

LevelRoleDescription
1OwnerFull control over the team
2Super AdminCan manage all members except owner
3AdminCan manage editors and viewers
4EditorCan edit team content
5ViewerRead-only access
OWNER (Level 1)
   │
   └── Can manage everyone
          │
          ▼
SUPER ADMIN (Level 2)
   │
   └── Can manage Admin, Editor, Viewer
          │
          ▼
ADMIN (Level 3)
   │
   └── Can manage Editor, Viewer
          │
          ▼
EDITOR (Level 4)
   │
   └── Cannot manage members
          │
          ▼
VIEWER (Level 5)
   │
   └── Read-only access

Role Enum

Roles are defined in app/models/team_member.ts:

export enum TeamMemberRole {
  OWNER = 'owner',
  SUPER_ADMIN = 'super-admin',
  ADMIN = 'admin',
  EDITOR = 'editor',
  VIEWER = 'viewer',
}

Permission Matrix

Team Actions

ActionOwnerSuper AdminAdminEditorViewer
View team
Update team
Delete team
Upload avatar
Delete avatar

Member Management

ActionOwnerSuper AdminAdminEditorViewer
View members
Send invitations
View invitations
Resend invitation
Cancel invitation
Update member role✅*✅*✅*
Remove member✅*✅*✅*
*Can only manage members with lower roles. Cannot manage self.

Role Comparison

The TeamMember model includes a method to compare roles:

public roleIsHigher(role: TeamMemberRole): boolean {
  const roles = Object.values(TeamMemberRole)
  const index = roles.indexOf(this.role)
  const indexOfRole = roles.indexOf(role)

  return index < indexOfRole
}

This is used in policies to prevent users from managing members with equal or higher roles.


Team Policy

Authorization is handled by app/policies/team_policy.ts:

Policy Methods

MethodDescriptionRequired Role
updateUpdate team detailsOwner, Super Admin
deleteDelete teamOwner only
getMembersView team membersOwner, Super Admin, Admin
sendInvitesSend invitationsOwner, Super Admin, Admin
getInvitationsView pending invitationsOwner, Super Admin, Admin
deleteInvitationCancel invitationOwner, Super Admin, Admin
resendInviteResend invitation emailOwner, Super Admin, Admin
updateMemberRoleChange member's roleOwner, Super Admin, Admin*
deleteMemberRemove from teamOwner, Super Admin, Admin*
uploadAvatarUpload team avatarOwner, Super Admin, Admin
deleteAvatarRemove team avatarOwner, Super Admin, Admin

Using Policies

In Controllers

import TeamPolicy from '#policies/team_policy'

export default class UpdateTeamController {
  public async execute({ request, response, bouncer }: HttpContext) {
    const team = await Team.findByOrFail('id', request.param('id'))

    // Check authorization
    await bouncer.with(TeamPolicy).authorize('update', team)

    // User is authorized, proceed with update
    team.name = request.input('name')
    await team.save()

    return response.ok({ message: 'team updated' })
  }
}

With Affected Member

For actions that affect another member:

export default class DeleteTeamMemberController {
  public async execute({ request, response, bouncer }: HttpContext) {
    const team = await Team.findByOrFail('id', request.param('id'))
    const teamMember = await TeamMember.query()
      .where('user_id', request.param('userId'))
      .where('team_id', team.id)
      .firstOrFail()

    // Pass the affected member to the policy
    await bouncer.with(TeamPolicy).authorize('deleteMember', team, teamMember)

    await teamMember.delete()
    return response.ok({ message: 'member removed' })
  }
}

Policy Logic Examples

Delete Team (Owner Only)

async delete(user: User, team: Team): Promise<AuthorizerResponse> {
  const member = await TeamMember.query()
    .where('user_id', user.id)
    .andWhere('team_id', team.id)
    .firstOrFail()

  return (
    [TeamMemberRole.OWNER].includes(member.role) &&
    !member.default  // Cannot delete default team
  )
}

Update Member Role (With Hierarchy Check)

async updateMemberRole(
  user: User,
  team: Team,
  affectedMember: TeamMember
): Promise<AuthorizerResponse> {
  const performingMember = await TeamMember.query()
    .where('user_id', user.id)
    .andWhere('team_id', team.id)
    .firstOrFail()

  // Cannot change own role
  if (user.id === affectedMember.userId) {
    return false
  }

  // Cannot manage members with equal or higher role
  if (affectedMember.roleIsHigher(performingMember.role)) {
    return false
  }

  return [TeamMemberRole.OWNER, TeamMemberRole.SUPER_ADMIN, TeamMemberRole.ADMIN]
    .includes(performingMember.role)
}

Special Rules

Cannot Manage Self

Users cannot:

  • Change their own role
  • Remove themselves from a team

This prevents accidental lockouts and ensures teams always have management.

Cannot Manage Higher Roles

  • An Admin cannot demote a Super Admin
  • A Super Admin cannot remove the Owner

Cannot Delete Default Team

The team marked as default for the owner cannot be deleted. This ensures users always have at least one team.

Single Owner

Each team has exactly one owner. The owner role:

  • Cannot be assigned via invitation
  • Cannot be transferred to another member
  • Is automatically assigned when creating a team

Customization

Adding a New Role

  1. Update the enum in app/models/team_member.ts:
export enum TeamMemberRole {
  OWNER = 'owner',
  SUPER_ADMIN = 'super-admin',
  ADMIN = 'admin',
  MODERATOR = 'moderator',  // New role
  EDITOR = 'editor',
  VIEWER = 'viewer',
}
  1. Create a migration to update the database enum:
// database/migrations/xxx_add_moderator_role.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  async up() {
    this.schema.raw(`
      ALTER TYPE team_members_role_enum 
      ADD VALUE 'moderator' AFTER 'admin'
    `)
  }
}
  1. Update policies to include the new role where appropriate.

Adding a New Permission

  1. Add method to policy:
async publishContent(user: User, team: Team): Promise<AuthorizerResponse> {
  const member = await TeamMember.query()
    .where('user_id', user.id)
    .andWhere('team_id', team.id)
    .firstOrFail()

  return [
    TeamMemberRole.OWNER,
    TeamMemberRole.SUPER_ADMIN,
    TeamMemberRole.ADMIN,
    TeamMemberRole.EDITOR,
  ].includes(member.role)
}
  1. Use in controller:
await bouncer.with(TeamPolicy).authorize('publishContent', team)

Frontend Integration

Checking Permissions

The frontend receives the user's role in team responses:

interface Team {
  id: number
  name: string
  role: 'owner' | 'super-admin' | 'admin' | 'editor' | 'viewer'
  default: boolean
}

Conditional UI

<template>
  <div>
    <!-- Only show for admins and above -->
    <Button v-if="canManageMembers" @click="openInviteModal">
      Invite Member
    </Button>
    
    <!-- Only show for owner -->
    <Button v-if="isOwner" variant="destructive" @click="deleteTeam">
      Delete Team
    </Button>
  </div>
</template>

<script setup lang="ts">
const teamStore = useTeamStore()
const currentTeam = computed(() => teamStore.getCurrentTeam())

const isOwner = computed(() => currentTeam.value?.role === 'owner')

const canManageMembers = computed(() => 
  ['owner', 'super-admin', 'admin'].includes(currentTeam.value?.role || '')
)
</script>

Resources