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.
Teams use five permission levels, ordered from highest to lowest:
| Level | Role | Description |
|---|---|---|
| 1 | Owner | Full control over the team |
| 2 | Super Admin | Can manage all members except owner |
| 3 | Admin | Can manage editors and viewers |
| 4 | Editor | Can edit team content |
| 5 | Viewer | Read-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
Roles are defined in app/models/team_member.ts:
export enum TeamMemberRole {
OWNER = 'owner',
SUPER_ADMIN = 'super-admin',
ADMIN = 'admin',
EDITOR = 'editor',
VIEWER = 'viewer',
}
| Action | Owner | Super Admin | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| View team | ✅ | ✅ | ✅ | ✅ | ✅ |
| Update team | ✅ | ✅ | ❌ | ❌ | ❌ |
| Delete team | ✅ | ❌ | ❌ | ❌ | ❌ |
| Upload avatar | ✅ | ✅ | ✅ | ❌ | ❌ |
| Delete avatar | ✅ | ✅ | ✅ | ❌ | ❌ |
| Action | Owner | Super Admin | Admin | Editor | Viewer |
|---|---|---|---|---|---|
| View members | ✅ | ✅ | ✅ | ❌ | ❌ |
| Send invitations | ✅ | ✅ | ✅ | ❌ | ❌ |
| View invitations | ✅ | ✅ | ✅ | ❌ | ❌ |
| Resend invitation | ✅ | ✅ | ✅ | ❌ | ❌ |
| Cancel invitation | ✅ | ✅ | ✅ | ❌ | ❌ |
| Update member role | ✅* | ✅* | ✅* | ❌ | ❌ |
| Remove member | ✅* | ✅* | ✅* | ❌ | ❌ |
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.
Authorization is handled by app/policies/team_policy.ts:
| Method | Description | Required Role |
|---|---|---|
update | Update team details | Owner, Super Admin |
delete | Delete team | Owner only |
getMembers | View team members | Owner, Super Admin, Admin |
sendInvites | Send invitations | Owner, Super Admin, Admin |
getInvitations | View pending invitations | Owner, Super Admin, Admin |
deleteInvitation | Cancel invitation | Owner, Super Admin, Admin |
resendInvite | Resend invitation email | Owner, Super Admin, Admin |
updateMemberRole | Change member's role | Owner, Super Admin, Admin* |
deleteMember | Remove from team | Owner, Super Admin, Admin* |
uploadAvatar | Upload team avatar | Owner, Super Admin, Admin |
deleteAvatar | Remove team avatar | Owner, Super Admin, Admin |
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' })
}
}
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' })
}
}
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
)
}
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)
}
Users cannot:
This prevents accidental lockouts and ensures teams always have management.
The team marked as default for the owner cannot be deleted. This ensures users always have at least one team.
Each team has exactly one owner. The owner role:
app/models/team_member.ts:export enum TeamMemberRole {
OWNER = 'owner',
SUPER_ADMIN = 'super-admin',
ADMIN = 'admin',
MODERATOR = 'moderator', // New role
EDITOR = 'editor',
VIEWER = 'viewer',
}
// 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'
`)
}
}
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)
}
await bouncer.with(TeamPolicy).authorize('publishContent', team)
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
}
<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>