Skip to content

Invitation System

Overview

The AppVector platform uses a secure token-based invitation system for workspace and project access. Users can only accept invitations when their email matches the invitation, ensuring secure and controlled access management.

Key Features: - ✅ Token-based invitations (UUID) - ✅ Email verification required for acceptance - ✅ 7-day expiration (configurable) - ✅ Multiple invitation states (PENDING, ACCEPTED, EXPIRED, CANCELLED) - ✅ Professional HTML email notifications - ✅ Automatic cleanup of expired invitations


How It Works

Workspace Invitations

1. Admin Creates Invitation

POST /api/workspaces/{workspace_id}/members/
Authorization: Bearer JWT_TOKEN
Content-Type: application/json

{
  "user_email": "[email protected]",
  "role": "ADMIN"
}

Response:

{
  "id": "uuid",
  "invitee_email": "[email protected]",
  "role": "ADMIN",
  "status": "PENDING",
  "expires_at": "2025-11-13T14:00:00Z",
  "created_at": "2025-11-06T14:00:00Z"
}

2. User Receives Email - Subject: "You've been invited to join [Workspace Name]" - Contains: Inviter name, workspace name, role, expiration date - Link: http://localhost:3000/invitations/workspace/{token}

3. User Clicks Link - Frontend receives token from URL - User must be logged in (or sign up/login first) - User's email MUST match invitation email

4. Frontend Accepts Invitation

POST /api/invitations/workspace/{token}/accept/
Authorization: Bearer USER_JWT_TOKEN

5. Backend Validates & Creates Access - ✅ Token valid and not expired - ✅ User email matches invitation email - ✅ User not already a member - Creates WorkspaceUser record - Marks invitation as ACCEPTED


Project Invitations

1. Admin Shares Project

POST /api/workspaces/{workspace_id}/projects/{project_id}/share/
Authorization: Bearer JWT_TOKEN
Content-Type: application/json

{
  "user_email": "[email protected]",
  "role": "VIEWER"
}

Requirements: - User MUST already be a workspace member - Cannot invite users who aren't in the workspace

Response:

{
  "id": "uuid",
  "invitee_email": "[email protected]",
  "role": "VIEWER",
  "status": "PENDING",
  "expires_at": "2025-11-13T14:00:00Z",
  "created_at": "2025-11-06T14:00:00Z"
}

2. User Receives Email - Subject: "Project Shared: [Project Name]" - Contains: Sharer name, project name, role, access details - Link: http://localhost:3000/invitations/project/{token}

3. User Accepts Invitation

POST /api/invitations/project/{token}/accept/
Authorization: Bearer USER_JWT_TOKEN

4. Backend Validates & Creates Access - ✅ Token valid and not expired - ✅ User email matches invitation email - ✅ User is a workspace member - ✅ User doesn't already have project access - Creates ProjectUserAccess record - Marks invitation as ACCEPTED


API Endpoints

Workspace Invitations

Method Endpoint Description
POST /api/workspaces/{id}/members/ Create workspace invitation
GET /api/invitations/workspace/ List my pending workspace invitations
GET /api/invitations/workspace/{token}/ Get invitation details by token
POST /api/invitations/workspace/{token}/accept/ Accept workspace invitation
POST /api/invitations/workspace/{token}/cancel/ Cancel invitation (invitee only)

Project Invitations

Method Endpoint Description
POST /api/workspaces/{id}/projects/{pid}/share/ Create project invitation
GET /api/invitations/project/ List my pending project invitations
GET /api/invitations/project/{token}/ Get invitation details by token
POST /api/invitations/project/{token}/accept/ Accept project invitation
POST /api/invitations/project/{token}/cancel/ Cancel invitation (invitee only)

Invitation Lifecycle

┌─────────────────┐
│  Admin Creates  │
│   Invitation    │
└────────┬────────┘
┌─────────────────┐
│    PENDING      │ ◄──────┐
│  (7 day TTL)    │        │
└────────┬────────┘        │
         │                  │
         ├─► Email Sent ────┘
         ├─► User Clicks Link
┌─────────────────┐
│ User Validates  │
│  Email Match    │
└────────┬────────┘
    ┌────┴────┐
    │         │
    ▼         ▼
ACCEPTED   EXPIRED
Access
Granted

Security Features

Email Verification

Users can ONLY accept invitations sent to their email:

def can_be_accepted_by(self, user):
    """Check if user can accept invitation."""
    return (
        self.is_valid() and
        user.email.lower() == self.invitee_email.lower()
    )

Example: - Invitation sent to: [email protected] - User logged in as: [email protected] - Result: ❌ Cannot accept (email mismatch)

Token-Based URLs

  • Each invitation has a unique UUID token
  • Tokens cannot be guessed or brute-forced
  • Example: /invitations/workspace/a1b2c3d4-e5f6-7890-abcd-ef1234567890

Expiration

  • Default: 7 days from creation
  • Automatically enforced when accepting
  • Can be configured per invitation

Single Use

  • Once accepted, invitation status changes to ACCEPTED
  • Cannot be accepted again
  • Original invitation email link becomes invalid

Role-Based Creation

  • Only workspace owners/admins can create workspace invitations
  • Only project admins can create project invitations
  • Enforced via Django permissions

Database Models

WorkspaceInvitation

class WorkspaceInvitation(BaseModel):
    workspace = ForeignKey(Workspace)        # Which workspace
    invitee_email = EmailField()             # Who is invited
    role = CharField()                        # OWNER, ADMIN, or VIEWER
    token = UUIDField(unique=True)           # Unique invitation token
    invited_by = ForeignKey(User)            # Who sent it
    status = CharField()                      # PENDING, ACCEPTED, EXPIRED, CANCELLED
    expires_at = DateTimeField()             # When it expires
    accepted_at = DateTimeField(null=True)   # When accepted
    accepted_by = ForeignKey(User, null=True) # Who accepted

Unique Constraint: (workspace, invitee_email, status) - Prevents duplicate pending invitations to same email

ProjectInvitation

class ProjectInvitation(BaseModel):
    project = ForeignKey(Project)            # Which project
    invitee_email = EmailField()             # Who is invited
    role = CharField()                        # ADMIN or VIEWER
    token = UUIDField(unique=True)           # Unique invitation token
    invited_by = ForeignKey(User)            # Who sent it
    status = CharField()                      # PENDING, ACCEPTED, EXPIRED, CANCELLED
    expires_at = DateTimeField()             # When it expires
    accepted_at = DateTimeField(null=True)   # When accepted
    accepted_by = ForeignKey(User, null=True) # Who accepted

Unique Constraint: (project, invitee_email, status) - Prevents duplicate pending invitations to same email


Email Templates

Workspace Invitation Email

Subject: "You've been invited to join [Workspace Name]"

Content:

Hi there,

[Inviter Name] has invited you to join the [Workspace Name] workspace on AppVector!

Invitation Details:
• Workspace: [Workspace Name]
• Role: [ADMIN/VIEWER]
• Invited By: [Inviter Name]
• Expires In: 7 days

As an [ROLE], you'll be able to collaborate with the team and access
shared projects and analytics.

[Accept Invitation Button] → /invitations/workspace/{token}

If you don't have an AppVector account yet, you'll be prompted to
create one. It only takes a minute!

Note: This invitation link will expire in 7 days.

Project Invitation Email

Subject: "Project Shared: [Project Name]"

Content:

Hi [User Name],

[Sharer Name] has shared a project with you!

Project Details:
• Project: [Project Name]
• Shared By: [Sharer Name]
• Your Role: [ADMIN/VIEWER]

You now have access to this project. You can view project
data and analytics.

[Open Project Button] → /invitations/project/{token}

You can find this project in your workspace dashboard along with
any other projects you have access to.

Happy tracking!


Error Handling

Common Errors

1. Email Mismatch

{
  "error": "Your email does not match the invitation email"
}
Cause: User's email doesn't match invitation Solution: Log in with the correct email address

2. Invitation Expired

{
  "error": "Invitation has expired or is no longer valid"
}
Cause: More than 7 days have passed Solution: Ask admin to send a new invitation

3. Already a Member

{
  "error": "User with email '[email protected]' is already a member"
}
Cause: User already has access Solution: No action needed, access already granted

4. Not a Workspace Member (Project Invitations)

{
  "error": "User must be a workspace member before being invited to projects"
}
Cause: Trying to share project with non-workspace member Solution: First invite user to workspace, then share project

5. Duplicate Invitation

{
  "error": "Pending invitation already exists for '[email protected]'"
}
Cause: Already sent pending invitation to this email Solution: Wait for user to accept, or cancel and resend


Frontend Integration

// URL: /invitations/workspace/{token}
// Extract token from URL

const { token } = useParams();
const invitationType = pathname.includes('/workspace/') ? 'workspace' : 'project';

Step 2: Check User Authentication

const isAuthenticated = !!localStorage.getItem('access_token');

if (!isAuthenticated) {
  // Redirect to login with return URL
  navigate(`/login?redirect=/invitations/${invitationType}/${token}`);
}

Step 3: Fetch Invitation Details

const response = await fetch(
  `http://localhost/api/invitations/${invitationType}/${token}/`,
  {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    }
  }
);

const invitation = await response.json();

Step 4: Show Invitation Preview

<InvitationPreview
  workspaceName={invitation.workspace_name}
  inviterEmail={invitation.invited_by_email}
  role={invitation.role}
  expiresAt={invitation.expires_at}
/>

Step 5: Accept Invitation

const acceptInvitation = async () => {
  const response = await fetch(
    `http://localhost/api/invitations/${invitationType}/${token}/accept/`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      }
    }
  );

  if (response.ok) {
    const access = await response.json();
    // Redirect to workspace or project
    navigate(`/workspaces/${access.workspace_id}`);
  } else {
    const error = await response.json();
    setError(error.error);
  }
};

Testing

Test Workspace Invitation Flow

1. Create Test User

docker compose run --rm web uv run python manage.py shell

from django.contrib.auth import get_user_model
User = get_user_model()

user = User.objects.create_user(
    username='testuser',
    email='[email protected]',
    password='testpass123'
)

2. Create Workspace Invitation

curl -X POST http://localhost/api/workspaces/{workspace_id}/members/ \
  -H "Authorization: Bearer ADMIN_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "user_email": "[email protected]",
    "role": "ADMIN"
  }'

3. Check Email in Logs

docker compose logs web | grep -i "invitation email sent"

4. Accept Invitation

curl -X POST http://localhost/api/invitations/workspace/{token}/accept/ \
  -H "Authorization: Bearer TESTUSER_JWT_TOKEN"

5. Verify Access

curl http://localhost/api/workspaces/{workspace_id}/ \
  -H "Authorization: Bearer TESTUSER_JWT_TOKEN"


Admin Panel

Managing Invitations

Access: http://localhost/admin/workspaces/

Workspace Invitations - View all invitations with status - Filter by status, role, creation date, expiration - Search by email, workspace name, inviter email - View token, invited by, accepted by details

Project Invitations - View all invitations with status - Filter by status, role, creation date, expiration - Search by email, project name, inviter email - View token, invited by, accepted by details

Actions: - View invitation details - Check who invited whom - Monitor acceptance rates - Manually cancel invitations if needed


Best Practices

For Administrators

  1. Verify Email Before Inviting
  2. Double-check email address
  3. Email typos result in failed invitations

  4. Use Appropriate Roles

  5. OWNER: Full control (workspace owners only)
  6. ADMIN: Can manage workspace and projects
  7. VIEWER: Read-only access

  8. Monitor Pending Invitations

  9. Check admin panel regularly
  10. Follow up on unaccepted invitations
  11. Cancel and resend if needed

  12. Workspace Before Projects

  13. Always invite users to workspace first
  14. Then share specific projects

For Users

  1. Check Spam Folder
  2. Invitation emails may be filtered
  3. Add [email protected] to contacts

  4. Use Correct Email

  5. Log in with email that received invitation
  6. Cannot accept with different email

  7. Act Before Expiration

  8. Invitations expire in 7 days
  9. Request new invitation if expired

  10. Accept Workspace First

  11. Must accept workspace invitation before projects
  12. Project invitations won't work without workspace access

Troubleshooting

"Email does not match"

Problem: User's login email doesn't match invitation Solution: 1. Check which email received the invitation 2. Log out and log in with correct email 3. Or ask admin to send invitation to current email

"Invitation expired"

Problem: More than 7 days have passed Solution: 1. Ask admin to send a new invitation 2. Admin must cancel old invitation first (if system prevents duplicates)

"Already a member"

Problem: User already has access Solution: 1. Check workspace/project access in dashboard 2. No action needed if already have access

Email Not Received

Problem: Invitation email not in inbox Solution: 1. Check spam/junk folder 2. Verify email address was correct 3. Check server logs: docker compose logs web | grep email 4. Verify EMAIL_BACKEND configured correctly

Cannot Accept Project Invitation

Problem: "Must be a workspace member" Solution: 1. Accept workspace invitation first 2. Then accept project invitation 3. Must be done in this order


Configuration

Expiration Time

# workspaces/models/invitation.py

def save(self, *args, **kwargs):
    if not self.expires_at:
        # Change default expiration
        self.expires_at = timezone.now() + timedelta(days=14)  # 14 days instead of 7
    super().save(*args, **kwargs)

Email URLs

# .env

# Development
FRONTEND_URL=http://localhost:3000

# Production
FRONTEND_URL=https://app.yourdomain.com

Email Backend

# Development (console logging)
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend

# Production (SMTP)
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=[email protected]
EMAIL_HOST_PASSWORD=your-app-password


Summary

Secure token-based invitations with unique UUID tokens ✅ Email verification required - users can only accept invitations sent to their email ✅ 7-day expiration with automatic enforcement ✅ Professional HTML emails with clear instructions ✅ Multiple invitation states (PENDING, ACCEPTED, EXPIRED, CANCELLED) ✅ Role-based access control at workspace and project levels ✅ Error-resilient - clear error messages guide users ✅ Admin-friendly - full management via Django admin panel

The invitation system ensures secure, controlled, and user-friendly workspace and project access management!