feat: Integrate django-invitations for user invitation management and update settings

This commit is contained in:
Sean Morley
2026-01-11 20:49:36 -05:00
parent fda1d039fd
commit 997a45581c
11 changed files with 220 additions and 9 deletions

View File

@@ -44,6 +44,7 @@ ALLOWED_HOSTS = ['*'] # In production, restrict to known hosts.
# Installed Apps
# ---------------------------------------------------------------------------
INSTALLED_APPS = (
"allauth_ui",
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -51,7 +52,6 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# "allauth_ui",
'rest_framework',
'rest_framework.authtoken',
'allauth',
@@ -61,6 +61,7 @@ INSTALLED_APPS = (
'allauth.socialaccount',
'allauth.socialaccount.providers.github',
'allauth.socialaccount.providers.openid_connect',
'invitations',
'drf_yasg',
'djmoney',
'corsheaders',
@@ -70,8 +71,8 @@ INSTALLED_APPS = (
'integrations',
'django.contrib.gis',
# 'achievements', # Not done yet, will be added later in a future update
# 'widget_tweaks',
# 'slippers',
'widget_tweaks',
'slippers',
)
@@ -220,6 +221,8 @@ TEMPLATES = [
},
]
ALLAUTH_UI_THEME = "dim"
# ---------------------------------------------------------------------------
# Authentication & Accounts
# ---------------------------------------------------------------------------
@@ -230,6 +233,9 @@ SOCIALACCOUNT_ALLOW_SIGNUP = getenv('SOCIALACCOUNT_ALLOW_SIGNUP', 'false').lower
AUTH_USER_MODEL = 'users.CustomUser'
ACCOUNT_ADAPTER = 'users.adapters.CustomAccountAdapter'
INVITATIONS_ADAPTER = ACCOUNT_ADAPTER
INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True
INVITATIONS_EMAIL_SUBJECT_PREFIX = 'AdventureLog: '
SOCIALACCOUNT_ADAPTER = 'users.adapters.CustomSocialAccountAdapter'
ACCOUNT_SIGNUP_FORM_CLASS = 'users.form_overrides.CustomSignupForm'
@@ -237,6 +243,8 @@ SESSION_SAVE_EVERY_REQUEST = True
LOGIN_REDIRECT_URL = FRONTEND_URL # Redirect to frontend after login
SOCIALACCOUNT_LOGIN_ON_GET = True
INVITATIONS_INVITE_FORM = 'users.form_overrides.UseAdminInviteForm'
INVITATIONS_SIGNUP_REDIRECT_URL = f"{FRONTEND_URL}/signup"
HEADLESS_FRONTEND_URLS = {
"account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}",

View File

@@ -33,6 +33,8 @@ urlpatterns = [
path('csrf/', get_csrf_token, name='get_csrf_token'),
path('public-url/', get_public_url, name='get_public_url'),
path("invitations/", include('invitations.urls', namespace='invitations')),
path('', TemplateView.as_view(template_name='home.html')),

View File

@@ -2,6 +2,7 @@ Django==5.2.8
djangorestframework>=3.15.2
django-allauth==0.63.3
django-money==3.5.4
django-invitations==2.1.0
drf-yasg==1.21.4
django-cors-headers==4.4.0
coreapi==2.3.3
@@ -15,7 +16,7 @@ setuptools==79.0.1
gunicorn==23.0.0
qrcode==8.0
slippers==0.6.2
django-allauth-ui==1.5.1
django-allauth-ui==1.7.0
django-widget-tweaks==1.5.0
django-ical==1.9.2
icalendar==6.1.0

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -0,0 +1,3 @@
{% extends "admin/base.html" %} {% load static %} {% block extrahead %}
<link rel="icon" href="{% static 'favicon.png' %}" />
{% endblock %}

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="AdventureLog API Server" />
<link rel="icon" href="/static/favicon.png" />
<meta name="author" content="Sean Morley" />
<title>AdventureLog API Server</title>

View File

@@ -0,0 +1,91 @@
{% load i18n %} {% autoescape off %} {% blocktrans %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>You're Invited to AdventureLog!</title>
<style>
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
background-color: #f0f4f8;
margin: 0;
padding: 0;
color: #1f2937;
}
.container {
max-width: 600px;
margin: 50px auto;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
padding: 40px 30px;
text-align: center;
}
img.logo {
width: 80px;
height: 80px;
margin-bottom: 20px;
}
h1 {
font-size: 28px;
color: #111827;
margin-bottom: 20px;
}
p {
font-size: 16px;
line-height: 1.6;
margin-bottom: 25px;
}
a.button {
display: inline-block;
padding: 16px 32px;
background: linear-gradient(90deg, #4f46e5, #6366f1);
color: #fff !important;
text-decoration: none;
font-weight: bold;
font-size: 16px;
border-radius: 12px;
transition: all 0.3s ease;
}
a.button:hover {
background: linear-gradient(90deg, #4338ca, #4f46e5);
transform: translateY(-2px);
}
.footer {
margin-top: 40px;
font-size: 12px;
color: #9ca3af;
text-align: center;
}
.adventure {
font-weight: bold;
color: #4f46e5;
}
</style>
</head>
<body>
<div class="container">
<img
src="https://adventurelog.app/adventurelog.png"
alt="AdventureLog Logo"
class="logo"
/>
<h1>You're Invited to AdventureLog!</h1>
<p>Hello <strong>{{ email }}</strong>,</p>
<p>
Adventure awaits! You've been invited to join
<span class="adventure">AdventureLog</span>, the ultimate travel
companion to track, plan, and collaborate on your journeys.
</p>
<p>Hit the button below to accept your invitation and start exploring!</p>
<p><a href="{{ invite_url }}" class="button">Join AdventureLog</a></p>
<div class="footer">
If you weren't expecting this email, no worries—you can safely ignore
it.
</div>
</div>
</body>
</html>
{% endblocktrans %} {% endautoescape %}

View File

@@ -0,0 +1,23 @@
{# templates/invitations/invite.html #} {% load i18n %}
<h4 class="title">{% trans "Send Invitation" %}</h4>
<div class="alert alert-info">
{% blocktrans %}To send an invitation, please go to the Django admin and
create a new Invitation object.{% endblocktrans %}
</div>
<p>
<a
href="{% url 'admin:invitations_invitation_add' %}"
class="btn btn-primary"
>
{% trans "Go to Django Admin" %}
</a>
</p>
{% if request.user.is_staff %}
<p class="text-muted small">
{% blocktrans %}Only staff users can send invitations.{% endblocktrans %}
</p>
{% endif %}

View File

@@ -0,0 +1,39 @@
{% extends "mfa/authenticate.html" %}
{% load allauth %}
{% load allauth_ui %}
{% load i18n %}
{% block content %}
{% trans "Two-Factor Authentication" as heading %}
{% blocktranslate asvar subheading %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
{% url 'mfa_authenticate' as action_url %}
{% #container heading=heading subheading=subheading %}
{% translate "Activate" as button_text %}
{% trans "Sign In" as button_text %}
{% #form form=form url=action_url button_text=button_text %}
{% csrf_token %}
{% /form %}
{% if "webauthn" in MFA_SUPPORTED_TYPES %}
<div class="divider"></div>
<h2 class="my-3 text-lg">{% translate "Alternative options" %}</h2>
{% #form form=webauthn_form url=action_url use_default_button="false" %}
<button type="submit" class="btn btn-neutral">
{% trans "Use a security key" %}
</button>
<a href="{% url "account_login" %}" class="btn btn-accent">{% trans "Cancel" %}</a>
{% csrf_token %}
{% /form %}
{% endif %}
{% /container %}
{{ js_data|json_script:"js_data" }}
{# djlint:off #}
<script type="text/javascript">
allauth.webauthn.forms.authenticateForm({
ids: {
authenticate: "mfa_webauthn_authenticate",
credential: "{{ webauthn_form.credential.auto_id }}"
},
data: JSON.parse(document.getElementById('js_data').textContent)
})
</script>
{# djlint:on #}
{% endblock content %}

View File

@@ -2,18 +2,48 @@
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.signals import user_signed_up
from django.conf import settings
from django.urls import resolve, Resolver404
from invitations.models import Invitation
class CustomAccountAdapter(DefaultAccountAdapter):
"""Control regular signup based on DISABLE_REGISTRATION setting"""
"""Control regular signup based on DISABLE_REGISTRATION, but allow invites."""
def is_open_for_signup(self, request):
"""
Determines if regular signup is allowed.
Check DISABLE_REGISTRATION env variable.
Allow signup only if:
- DISABLE_REGISTRATION is False, OR
- the request is for the invitation acceptance URL, OR
- there's a valid invitation key in the request parameters.
"""
return settings.DISABLE_REGISTRATION is False
# If registration is globally open, allow as usual
if settings.DISABLE_REGISTRATION is False:
return True
# If an invitation-verified email is stashed in the session, allow signup
if hasattr(request, "session") and request.session.get("account_verified_email"):
return True
# When disabled, allow signups via invitation accept URL
try:
match = resolve(request.path_info)
print("Resolved view name:", match.view_name)
if match.view_name == "invitations:accept-invite":
return True
except Resolver404:
pass
# Block any other signup
return False
def get_user_signed_up_signal(self):
"""Return the allauth `user_signed_up` signal for compatibility with
django-invitations which expects this method on the adapter.
"""
return user_signed_up
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):

View File

@@ -14,4 +14,17 @@ class CustomSignupForm(forms.Form):
# Save the user instance
user.save()
return user
return user
class UseAdminInviteForm(forms.Form):
"""
Dummy form that just tells admins to use the Django admin to send invites.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Remove any fields; we only want to show a message
self.fields.clear()
def as_widget(self):
# This is not needed; well just use a template
pass