Team Management

Invitations

Team invitation system.

Nuda Kit includes a complete invitation system for adding new members to teams. Invitations are sent via email and can be accepted or rejected by recipients.

Overview

The invitation system provides:

Email Invitations

Invitations are sent via email with a unique link.

Role Assignment

Assign a role when inviting (except Owner).

Expiration

Invitations expire after 24 hours.

Resend & Cancel

Admins can resend or cancel pending invitations.

Data Model

Invitations Table

ColumnTypeDescription
idIntegerPrimary key
emailStringInvitee's email address
tokenStringUnique invitation token
team_idIntegerForeign key to teams
roleEnumRole to assign on acceptance
expires_atTimestampWhen invitation expires
created_atTimestampWhen invitation was sent
updated_atTimestampLast update

Available Roles for Invitations

RoleDescription
super-adminCan manage all members except owner
adminCan manage lower-level members
editorCan edit team content
viewerRead-only access
The owner role cannot be assigned via invitation. Teams can only have one owner.

Invitation Flow

┌─────────────────────────────────────────────────────────────────┐
│                      INVITATION FLOW                             │
└─────────────────────────────────────────────────────────────────┘

1. Admin sends invitation
   │
   ▼
┌─────────────────────┐
│  Validate Request   │ ─── Check: Not already invited
│                     │ ─── Check: Not already a member
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Create Invitation  │ ─── Generate unique token
│                     │ ─── Set expiration (24h)
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│   Queue Email Job   │ ─── SendInvitationEmailJob
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│    Send Email       │ ─── Contains invitation link
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Recipient Clicks   │ ─── /invitations/{token}
│      Link           │
└──────────┬──────────┘
           │
     ┌─────┴─────┐
     ▼           ▼
┌─────────┐ ┌─────────┐
│ Accept  │ │ Reject  │
└────┬────┘ └────┬────┘
     │           │
     ▼           ▼
┌─────────┐ ┌─────────┐
│ Add to  │ │ Delete  │
│  Team   │ │ Invite  │
└─────────┘ └─────────┘

API Endpoints

Team Invitation Endpoints

MethodEndpointDescription
POST/teams/:id/invitationsSend invitation
GET/teams/:id/invitationsList pending invitations
POST/teams/:id/invitations/:invitationId/resendResend invitation
DELETE/teams/:id/invitations/:invitationIdCancel invitation

Invitation Response Endpoints

MethodEndpointDescription
GET/invitations/:tokenGet invitation details
POST/teams/:id/invitations/:token/acceptAccept invitation
POST/teams/:id/invitations/:token/rejectReject invitation

Sending Invitations

Request

POST /teams/:id/invitations
{
  "email": "newmember@example.com",
  "role": "editor"
}

Validation

Before creating an invitation, the system checks:

  1. Not already invited — No pending invitation exists for this email
  2. Not already a member — Email is not already a team member

Response

{
  "message": "team invite sent"
}

Email Sent

The recipient receives an email with:

  • Team name in subject: "You have been invited to join {Team Name}"
  • Invitation link: {FRONTEND_URL}/invitations/{token}

Accepting Invitations

Flow

  1. User clicks invitation link in email
  2. Frontend loads invitation details via GET /invitations/:token
  3. User clicks "Accept" button
  4. Frontend calls POST /teams/:id/invitations/:token/accept
  5. User is added to team with assigned role
  6. Invitation is deleted

Request

POST /teams/:id/invitations/:token/accept

Response

{
  "message": "team invitation accepted",
  "data": {
    "teamId": 1
  }
}

Error Cases

StatusMessageCause
422"invitation expired"Invitation is past expiration
409"already a member of this team"User joined via another invite
404Not foundInvalid token or wrong email

Rejecting Invitations

Request

POST /teams/:id/invitations/:token/reject

Response

{
  "message": "team invitation rejected",
  "data": {
    "teamId": 1
  }
}

The invitation is deleted after rejection.


Managing Invitations

List Pending Invitations

GET /teams/:id/invitations

Returns all pending invitations for a team.

Resend Invitation

POST /teams/:id/invitations/:invitationId/resend

Sends a new email with the same token. Useful if the original email was lost or expired.

Cancel Invitation

DELETE /teams/:id/invitations/:invitationId

Deletes the invitation. The link will no longer work.


Security

Token Generation

Tokens are generated using cryptographically secure random bytes:

static generateToken() {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  let result = ''
  for (let i = 0; i < 10; i++) {
    result += chars.charAt(Math.floor(crypto.randomBytes(1)[0] % chars.length))
  }
  return result
}

Expiration

  • Invitations expire 24 hours after creation
  • Expired invitations cannot be accepted
  • Admins can resend to generate a new expiration window

Email Verification

  • Invitations are tied to the recipient's email
  • Only the user with the matching email can accept
  • This prevents invitation link sharing

Authorization

ActionRequired Role
Send invitationOwner, Super Admin, Admin
View invitationsOwner, Super Admin, Admin
Resend invitationOwner, Super Admin, Admin
Cancel invitationOwner, Super Admin, Admin
Accept invitationInvited user only
Reject invitationInvited user only

Frontend Integration

Getting Invitation Details

// When user lands on /invitations/:token
const { data: invitation } = await useInvitationQuery().getInvitation(token)

Accepting an Invitation

const { mutate: acceptInvitation } = useTeamMutation().acceptInvitation

acceptInvitation({ 
  teamId: invitation.teamId, 
  token: invitation.token 
})

Listing Team Invitations

const { data: invitations } = useTeamQuery().getTeamInvitations(teamId)

Customization

Changing Expiration Time

Update the expiration duration in SendTeamInvitesController:

const invitation = await team.related('invitations').create({
  email: payload.email,
  role: payload.role,
  token: Invitation.generateToken(),
  expiresAt: DateTime.now().plus({ hours: 48 }), // Changed from 24 to 48
})

Custom Email Template

Edit the template at resources/views/emails/invitation_email_html.edge:

@mjml()
  <mjml>
    <mj-body>
      <!-- Customize invitation email here -->
      <mj-section>
        <mj-column>
          <mj-text>
            You've been invited to join our team!
          </mj-text>
          <mj-button href="{{ invitationUrl }}">
            Accept Invitation
          </mj-button>
        </mj-column>
      </mj-section>
    </mj-body>
  </mjml>
@end

Resources