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
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
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
Cause: User's email doesn't match invitation Solution: Log in with the correct email address2. Invitation Expired
Cause: More than 7 days have passed Solution: Ask admin to send a new invitation3. Already a Member
{
"error": "User with email '[email protected]' is already a member"
}
4. Not a Workspace Member (Project Invitations)
Cause: Trying to share project with non-workspace member Solution: First invite user to workspace, then share project5. Duplicate Invitation
{
"error": "Pending invitation already exists for '[email protected]'"
}
Frontend Integration
Step 1: Handle Invitation Link
// 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
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
- Verify Email Before Inviting
- Double-check email address
-
Email typos result in failed invitations
-
Use Appropriate Roles
- OWNER: Full control (workspace owners only)
- ADMIN: Can manage workspace and projects
-
VIEWER: Read-only access
-
Monitor Pending Invitations
- Check admin panel regularly
- Follow up on unaccepted invitations
-
Cancel and resend if needed
-
Workspace Before Projects
- Always invite users to workspace first
- Then share specific projects
For Users
- Check Spam Folder
- Invitation emails may be filtered
-
Add [email protected] to contacts
-
Use Correct Email
- Log in with email that received invitation
-
Cannot accept with different email
-
Act Before Expiration
- Invitations expire in 7 days
-
Request new invitation if expired
-
Accept Workspace First
- Must accept workspace invitation before projects
- 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
Related Documentation
- Email System Documentation - Complete email system guide
- Email Integration Guide - Email integration details
- Workspaces & Projects - Workspace and project management
- API Reference - Complete API documentation
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!