Django Team Invitation Acceptance With Allauth
Add workspace invitation acceptance to a generated Django app without replacing allauth verification, signup policy, or email hooks.
Django team invitations need two separate decisions: which account is signed in, and whether that account may join this workspace. Let django-allauth handle signup, login, and email verification. Keep the invitation and membership rules in your application, then grant membership through an explicit, CSRF-protected POST from the invited, verified account.
This guide covers that acceptance path for existing and new users in a generated Djass app. It does not add a team feature to the generator or provide a complete invitation package. You still implement invitation issuance, delivery, administration, and the workspace's resource permissions.
The short version:
- Keep the generated account adapters and mandatory email verification.
- Store a workspace-scoped invitation separately from the user account.
- Ask the recipient to log in or sign up and verify the invited address.
- Confirm the join with POST; create membership and consume the invitation in one transaction.
- Check current membership on every protected workspace operation.
Use this recipient-flow outline when implementing the screens; the experiment exercised the server flow, not browser navigation.
Existing user: open invitation → log in with the invited verified account → return to the invitation → confirm Join → perform a permitted workspace action. A different signed-in account must switch accounts, not inherit access.
New user: open invitation → sign up → enter the emailed verification code → return to the invitation → confirm Join → perform the workspace action. If registration is closed, explain that signup is paused; this example does not use an invitation to bypass that policy.
What Djass tested
On November 2, 2026, Djass rendered a repository from its bundled starter with optional integrations disabled. The runtime used Python 3.14.5; its lockfile resolved Django 6.1.2, django-allauth 65.21.1, and Django Q2 1.11.1. These are the versions of this local experiment, not version guarantees for your download. Inspect your own lockfile before copying the example.
The experiment added local workspace, membership, and invitation models. It
used Django's test client to submit the generated signup form, read the
verification code from an in-memory email, complete verification, and submit
the join. The generated account adapters were unchanged. Confirmation delivery
still called the custom hook and created its EmailSent record.
Acceptance and denial checks ran with SQLite and PostgreSQL 15.19. The PostgreSQL experiment also made concurrent acceptance-service calls. This was not a browser, real email-provider, social-login, or production deployment test. Local table creation bypassed the generated PostgreSQL extension migration; it did not validate a full deployment migration. Repeat the checks with your production database version and configuration before release.
1. Preserve the account policy you already have
The bundled starter provides Django's built-in User, a one-to-one Profile,
and custom account and social-account adapters. It does not provide workspace
or membership models. Start with the
generated-app authentication contract and the
authorization workflow
instead of treating a profile as permission to access a team.
In the generated project, inspect the project package's settings.py and
adapters.py. Its ACCOUNT_ADAPTER points to CustomAccountAdapter, which
preserves the ALLOW_SIGNUPS policy and wraps confirmation email delivery.
The social-account adapter also checks the signup policy.
Do not replace that setting with a package's adapter without deliberately composing and testing the behavior you would remove. The allauth adapter reference describes the account extension points; allauth's invitation guidance leaves invitation handling to a separate application.
For this experiment, an invitation did not override closed registration.
With ALLOW_SIGNUPS=False, an existing verified user could join, but a new
signup POST created no user. If your product needs invitation-only signup,
design that exception separately. Do not accidentally reopen registration as
a side effect of adding teams.
2. Give invitations and memberships different lifetimes
Keep the domain models in the generated repository's apps/core, following its
existing conventions. The acceptance example below assumes these application
models, which you add yourself:
Workspace: the resource boundary the recipient is joining.Membership:workspace,user, androle, with database uniqueness on the workspace/user pair. The example grants only the fixed rolemember.Invitation:workspace, invitedemail, a uniquetoken_digest,expires_at,revoked, and nullableaccepted_bypointing to the user.
Generate an unpredictable token when issuing an invitation and store its SHA-256 digest, not the raw URL token. Set workspace and role on the server. An ordinary member must not become an administrator by changing submitted form fields. Decide which current members may issue, revoke, or resend an invitation, and enforce that policy independently of acceptance.
Invitation emails are not globally unique in this design: the same address
can receive invitations to different workspaces. Membership uniqueness is a
different constraint. Use a database
UniqueConstraint
on Membership(workspace, user) so separate invitations cannot create
duplicate memberships.
Consumption also outlives membership. If an administrator removes a member, replaying an already-consumed invitation must not silently restore access. Require a newly authorized invitation for that transition.
3. Keep GET harmless and require the invited identity
An anonymous recipient should see a login/signup handoff, not gain membership. A signed-in recipient's GET should show a confirmation screen. Neither a mail scanner nor an accidental page visit should consume the invitation.
For an existing account, require a verified allauth EmailAddress matching
the invitation. Comparing only request.user.email skips the verification
state. The experiment used a case-insensitive address match; define your
product's address policy explicitly rather than adding provider-specific
alias normalization by guesswork.
For a new account, keep the mandatory verification configuration. Signup creates the account, verification establishes the address, and joining establishes membership. Those are separate transitions. Show a way back to the invitation after verification, including when the user resumes in another session; do not rely only on an uninterrupted signup redirect. For example, reopening the original link after verification should return the recipient to confirmation. Add an interrupted-session test for that recovery path; the local check below did not exercise it.
If the wrong account is signed in, ask the recipient to switch accounts. Do not merge identities or grant access based on the link alone. The confirmation screen should avoid disclosing private workspace details before the identity check.
4. Make acceptance atomic and replay-aware
This is the core service exercised in the local experiment, adapted to the
apps/core model location above. It is an acceptance example, not a complete
model, migration, view, or invitation-delivery implementation. It assumes an
authenticated user and maps rejection to a generic unavailable response in
the view.
import hashlib
from allauth.account.models import EmailAddress
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.utils import timezone
from apps.core.models import Invitation, Membership
@transaction.atomic
def accept_invitation(token, user):
if not user.is_authenticated:
raise PermissionDenied("Invitation unavailable")
invitation = Invitation.objects.select_for_update().get(
token_digest=hashlib.sha256(token.encode()).hexdigest(),
)
matches = EmailAddress.objects.filter(
user=user,
email__iexact=invitation.email,
verified=True,
).exists()
if not matches or invitation.revoked or invitation.expires_at <= timezone.now():
raise PermissionDenied("Invitation unavailable")
if invitation.accepted_by_id:
membership = Membership.objects.filter(
workspace=invitation.workspace,
user=user,
).first()
if invitation.accepted_by_id != user.pk or membership is None:
raise PermissionDenied("Invitation unavailable")
return membership
membership, _ = Membership.objects.get_or_create(
workspace=invitation.workspace,
user=user,
defaults={"role": "member"},
)
invitation.accepted_by = user
invitation.save(update_fields=["accepted_by"])
return membership
The code uses Django's
transaction boundary
and select_for_update().
The row lock serializes acceptance of the same invitation on PostgreSQL.
The membership constraint handles two different invitations for the same
workspace/user pair. Existing membership roles are not overwritten by a
repeat join.
Keep the view login-protected, permit only GET and POST, and leave Django's
CSRF middleware enabled. Call the service only on POST. Handle
Invitation.DoesNotExist as an unavailable invitation without returning
private lookup details. Do not put the token in application logs or analytics;
also review proxy/access logging and referrer handling for invitation URLs.
SQLite does not reproduce PostgreSQL's row-lock behavior. In the local PostgreSQL check, concurrent service calls using the same invitation returned the same membership. Concurrent service calls using separate invitations for the same account and workspace also returned one membership. These narrow checks are not proof of every race involving revocation, expiry, or account changes.
5. Test a workspace action, not just the join response
A successful acceptance response is not enough. The local experiment checked that the joined account could reach a membership-protected workspace view, could not reach an unrelated workspace, and lost access after membership removal. The consumed invitation did not recreate the removed membership.
Give your coding agent this acceptance checklist in the generated repository:
- Existing verified user: confirm by POST and obtain one membership.
- New user: sign up, complete the actual code challenge, return, and confirm.
- Before verification: no membership and no protected workspace action.
- Different signed-in account: reject without changing either membership.
- Anonymous GET and signed-in GET: no consumption or membership creation.
- POST without CSRF: reject when using session authentication.
- Expired or revoked invitation: reject, leaving access unchanged.
- Repeat and concurrent requests: no duplicate membership or role escalation.
- Removed member: protected action denied; consumed link does not rejoin.
- Forged workspace/role fields: do not change the server-selected destination or grant an elevated role.
Run these against your real database and cache classes. Test transaction locking with separate connections, not only a test wrapper that hides the transaction boundary. Then cover issuance permissions, resend/token rotation, interrupted verification, social-login policy, and email-provider failures. Those paths were outside this local experiment.
For invitation delivery, reuse the project's email operations guidance. If delivery is queued, define recovery after a database commit succeeds but dispatch fails; a successful commit callback is not a durable delivery guarantee. Never count email sent, link opened, or account created as successful teammate activation. Confirm that the intended verified teammate can complete a permitted workspace action.
Build the first teammate journey before widening the scope
If you are starting from Djass, use the generator-options decision table and generation workflow to obtain a repository, then implement this in that generated app. Generation requires the appropriate paid access; teams are not a generator option.
Start with one workspace, one fixed member role, and a tested acceptance path. Add administrator delegation, bulk invitations, and more flexible roles only when the product needs them. A working verified-account handoff is useful; a larger invitation surface without clear access rules is not the same outcome.