Invites overhaul

No email tie, added uses and expires, now works by URL.
This commit is contained in:
Andrew Godwin 2022-12-22 07:03:21 +00:00
parent ff38948b2f
commit 9c376395db
14 changed files with 178 additions and 104 deletions

View File

@ -25,9 +25,7 @@ def instance_info(request):
}, },
"thumbnail": Config.system.site_banner, "thumbnail": Config.system.site_banner,
"languages": ["en"], "languages": ["en"],
"registrations": ( "registrations": (Config.system.signup_allowed),
Config.system.signup_allowed and not Config.system.signup_invite_only
),
"approval_required": False, "approval_required": False,
"invites_enabled": False, "invites_enabled": False,
"configuration": { "configuration": {

View File

@ -211,7 +211,6 @@ class Config(models.Model):
policy_rules: str = "" policy_rules: str = ""
signup_allowed: bool = True signup_allowed: bool = True
signup_invite_only: bool = False
signup_text: str = "" signup_text: str = ""
content_warning_text: str = "Content Warning" content_warning_text: str = "Content Warning"

View File

@ -182,6 +182,7 @@ urlpatterns = [
path("auth/login/", auth.Login.as_view(), name="login"), path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"), path("auth/logout/", auth.Logout.as_view(), name="logout"),
path("auth/signup/", auth.Signup.as_view(), name="signup"), path("auth/signup/", auth.Signup.as_view(), name="signup"),
path("auth/signup/<token>/", auth.Signup.as_view(), name="signup"),
path("auth/reset/", auth.TriggerReset.as_view(), name="trigger_reset"), path("auth/reset/", auth.TriggerReset.as_view(), name="trigger_reset"),
path("auth/reset/<token>/", auth.PerformReset.as_view(), name="password_reset"), path("auth/reset/<token>/", auth.PerformReset.as_view(), name="password_reset"),
# Identity selection # Identity selection

View File

@ -7,7 +7,8 @@
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
<legend>Details</legend> <legend>Details</legend>
{% include "forms/_field.html" with field=form.email %} {% include "forms/_field.html" with field=form.uses %}
{% include "forms/_field.html" with field=form.expires_days %}
{% include "forms/_field.html" with field=form.notes %} {% include "forms/_field.html" with field=form.notes %}
</fieldset> </fieldset>
<div class="buttons"> <div class="buttons">

View File

@ -7,8 +7,9 @@
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
<legend>Details</legend> <legend>Details</legend>
{% include "forms/_field.html" with field=form.code %} {% include "forms/_field.html" with field=form.link %}
{% include "forms/_field.html" with field=form.email %} {% include "forms/_field.html" with field=form.uses %}
{% include "forms/_field.html" with field=form.expires_days %}
{% include "forms/_field.html" with field=form.notes %} {% include "forms/_field.html" with field=form.notes %}
</fieldset> </fieldset>
<div class="buttons"> <div class="buttons">

View File

@ -17,12 +17,22 @@
{{ invite.token }} {{ invite.token }}
<small> <small>
{% if invite.email %} {% if invite.expires %}
(email {{ invite.email }}) Expires in {{ invite.expires|timeuntil }}
{% if invite.notes %}|{% endif %}
{% endif %}
{% if invite.notes %}
{{ invite.notes }}
{% endif %} {% endif %}
</small> </small>
</span> </span>
<time>{{ invite.created|timedeltashort }} ago</time> <time>
{% if invite.uses %}
{{ invite.uses }} use{{ invite.uses|pluralize }} left
{% else %}
Infinite uses
{% endif %}
</time>
</a> </a>
{% empty %} {% empty %}
<p class="option empty"> <p class="option empty">

View File

@ -4,27 +4,27 @@
{% block content %} {% block content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% if form %}
<fieldset> {% csrf_token %}
<legend>Create An Account</legend> <fieldset>
{% if signup_text %}{{ signup_text }}{% endif %} <legend>Create An Account</legend>
{% if config.signup_allowed %} {% if signup_text %}{{ signup_text }}{% endif %}
{% for field in form %} {% for field in form %}
{% include "forms/_field.html" %} {% include "forms/_field.html" %}
{% endfor %} {% endfor %}
{% else %} </fieldset>
{% if not config.signup_text %} <div class="buttons">
<p>Not accepting new users at this time</p> <button>Create</button>
</div>
{% else %}
<fieldset>
<legend>Create An Account</legend>
{% if signup_text %}
{{ signup_text }}
{% else %}
<p>We're not accepting new users at this time.</p>
{% endif %} {% endif %}
{% endif %} </fieldset>
</fieldset>
{% if config.signup_allowed %}
<div class="buttons">
<button>Create</button>
</div>
{% endif %} {% endif %}
</form> </form>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,8 @@
import datetime
import pytest import pytest
from django.core import mail from django.core import mail
from django.utils import timezone
from pytest_django.asserts import assertContains, assertNotContains from pytest_django.asserts import assertContains, assertNotContains
from users.models import Invite, User from users.models import Invite, User
@ -13,7 +16,9 @@ def test_signup_disabled(client, config_system):
# Signup disabled and no signup text # Signup disabled and no signup text
config_system.signup_allowed = False config_system.signup_allowed = False
response = client.get("/auth/signup/") response = client.get("/auth/signup/")
assertContains(response, "Not accepting new users at this time", status_code=200) assertContains(
response, "We're not accepting new users at this time", status_code=200
)
assertNotContains(response, "<button>Create</button>") assertNotContains(response, "<button>Create</button>")
# Signup disabled with signup text configured # Signup disabled with signup text configured
@ -32,7 +37,7 @@ def test_signup_disabled(client, config_system):
config_system.signup_allowed = True config_system.signup_allowed = True
response = client.get("/auth/signup/") response = client.get("/auth/signup/")
assertContains(response, "<button>Create</button>", status_code=200) assertContains(response, "<button>Create</button>", status_code=200)
assertNotContains(response, "Not accepting new users at this time") assertNotContains(response, "We're not accepting new users at this time")
@pytest.mark.django_db @pytest.mark.django_db
@ -40,43 +45,45 @@ def test_signup_invite_only(client, config_system):
""" """
Tests that invite codes work with signup Tests that invite codes work with signup
""" """
config_system.signup_allowed = True config_system.signup_allowed = False
config_system.signup_invite_only = True
# Try to sign up without an invite code # Try to sign up without an invite code
response = client.post("/auth/signup/", {"email": "random@example.com"}) response = client.post("/auth/signup/", {"email": "random@example.com"})
assertNotContains(response, "Email Sent", status_code=200) assertNotContains(response, "Email Sent", status_code=200)
# Make an invite code for any email # Make an invite code for any email with infinite uses
invite_any = Invite.create_random() invite_infinite = Invite.create_random()
response = client.post( response = client.post(
"/auth/signup/", f"/auth/signup/{invite_infinite.token}/",
{"email": "random@example.com", "invite_code": invite_any.token}, {"email": "random@example.com"},
)
assertNotContains(response, "not a valid invite")
assertContains(response, "Email Sent", status_code=200)
# Make sure you can't reuse an invite code
response = client.post(
"/auth/signup/",
{"email": "random2@example.com", "invite_code": invite_any.token},
)
assertNotContains(response, "Email Sent", status_code=200)
# Make an invite code for a specific email
invite_specific = Invite.create_random(email="special@example.com")
response = client.post(
"/auth/signup/",
{"email": "random3@example.com", "invite_code": invite_specific.token},
)
assertContains(response, "valid invite code for this email", status_code=200)
assertNotContains(response, "Email Sent")
response = client.post(
"/auth/signup/",
{"email": "special@example.com", "invite_code": invite_specific.token},
) )
assertContains(response, "Email Sent", status_code=200) assertContains(response, "Email Sent", status_code=200)
# Ensure it still has infinite uses
assert Invite.objects.get(token=invite_infinite.token).uses is None
# Make an invite code for any email with one use
invite_single = Invite.create_random(uses=1)
response = client.post(
f"/auth/signup/{invite_single.token}/",
{"email": "random2@example.com"},
)
assertContains(response, "Email Sent", status_code=200)
# Verify it was used up
assert Invite.objects.filter(token=invite_single.token).count() == 0
# Make an invite code that's invalid
invite_expired = Invite.create_random(
expires=timezone.now() - datetime.timedelta(days=1)
)
response = client.post(
f"/auth/signup/{invite_expired.token}/",
{"email": "random3@example.com"},
)
print(response.content)
assert response.status_code == 404
@pytest.mark.django_db @pytest.mark.django_db
def test_signup_policy(client, config_system): def test_signup_policy(client, config_system):
@ -84,7 +91,6 @@ def test_signup_policy(client, config_system):
Tests that you must agree to policies to sign up Tests that you must agree to policies to sign up
""" """
config_system.signup_allowed = True config_system.signup_allowed = True
config_system.signup_invite_only = False
# Make sure we can sign up when there are no policies # Make sure we can sign up when there are no policies
response = client.post("/auth/signup/", {"email": "random@example.com"}) response = client.post("/auth/signup/", {"email": "random@example.com"})
@ -103,7 +109,6 @@ def test_signup_email(client, config_system, stator):
Tests that you can sign up and get an email sent to you Tests that you can sign up and get an email sent to you
""" """
config_system.signup_allowed = True config_system.signup_allowed = True
config_system.signup_invite_only = False
# Sign up with a user # Sign up with a user
response = client.post("/auth/signup/", {"email": "random@example.com"}) response = client.post("/auth/signup/", {"email": "random@example.com"})

View File

@ -0,0 +1,27 @@
# Generated by Django 4.1.4 on 2022-12-22 06:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0006_identity_actor_type"),
]
operations = [
migrations.RemoveField(
model_name="invite",
name="email",
),
migrations.AddField(
model_name="invite",
name="expires",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="invite",
name="uses",
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -2,6 +2,7 @@ import random
import urlman import urlman
from django.db import models from django.db import models
from django.utils import timezone
class Invite(models.Model): class Invite(models.Model):
@ -12,12 +13,15 @@ class Invite(models.Model):
# Should always be lowercase # Should always be lowercase
token = models.CharField(max_length=500, unique=True) token = models.CharField(max_length=500, unique=True)
# Is it limited to a specific email?
email = models.EmailField(null=True, blank=True)
# Admin note about this code # Admin note about this code
note = models.TextField(null=True, blank=True) note = models.TextField(null=True, blank=True)
# Uses remaining (null means "infinite")
uses = models.IntegerField(null=True, blank=True)
# Expiry date
expires = models.DateTimeField(null=True, blank=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
@ -27,11 +31,21 @@ class Invite(models.Model):
admin_view = "{admin}{self.pk}/" admin_view = "{admin}{self.pk}/"
@classmethod @classmethod
def create_random(cls, email=None, note=None): def create_random(cls, uses=None, expires=None, note=None):
return cls.objects.create( return cls.objects.create(
token="".join( token="".join(
random.choice("abcdefghkmnpqrstuvwxyz23456789") for i in range(20) random.choice("abcdefghkmnpqrstuvwxyz23456789") for i in range(20)
), ),
email=email, uses=uses,
expires=expires,
note=note, note=note,
) )
@property
def valid(self):
if self.uses is not None:
if self.uses <= 0:
return False
if self.expires is not None:
return self.expires >= timezone.now()
return True

View File

@ -82,8 +82,7 @@ class NodeInfo2(View):
"users": {"total": local_identities}, "users": {"total": local_identities},
"localPosts": local_posts, "localPosts": local_posts,
}, },
"openRegistrations": Config.system.signup_allowed "openRegistrations": Config.system.signup_allowed,
and not Config.system.signup_invite_only,
"metadata": {}, "metadata": {},
} }
) )

View File

@ -1,5 +1,9 @@
import datetime
from django import forms from django import forms
from django.conf import settings
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView, ListView from django.views.generic import FormView, ListView
@ -32,19 +36,28 @@ class InviteCreate(FormView):
} }
class form_class(forms.Form): class form_class(forms.Form):
email = forms.EmailField( uses = forms.IntegerField(
required=False, required=False,
help_text="Optional email to tie the invite to.\nYou will still need to email the user this code yourself!", help_text="Number of times this can be used. Leave blank for infinite uses.",
)
expires_days = forms.IntegerField(
required=False,
help_text="Number of days until this expires. Leave blank to make it last forever.",
) )
notes = forms.CharField( notes = forms.CharField(
required=False, required=False,
widget=forms.Textarea,
help_text="Notes for other admins", help_text="Notes for other admins",
) )
def form_valid(self, form): def form_valid(self, form):
expires_days = form.cleaned_data.get("expires_days")
invite = Invite.create_random( invite = Invite.create_random(
email=form.cleaned_data.get("email") or None, uses=form.cleaned_data.get("uses") or None,
expires=(
timezone.now() + datetime.timedelta(days=expires_days)
if expires_days is not None
else None
),
note=form.cleaned_data.get("notes"), note=form.cleaned_data.get("notes"),
) )
return redirect(invite.urls.admin_view) return redirect(invite.urls.admin_view)
@ -59,7 +72,7 @@ class InviteView(FormView):
} }
class form_class(InviteCreate.form_class): class form_class(InviteCreate.form_class):
code = forms.CharField(disabled=True, required=False) link = forms.CharField(disabled=True, required=False)
def dispatch(self, request, id, *args, **kwargs): def dispatch(self, request, id, *args, **kwargs):
self.invite = get_object_or_404(Invite, id=id) self.invite = get_object_or_404(Invite, id=id)
@ -74,13 +87,19 @@ class InviteView(FormView):
def get_initial(self): def get_initial(self):
return { return {
"notes": self.invite.note, "notes": self.invite.note,
"email": self.invite.email, "uses": self.invite.uses,
"code": self.invite.token, "link": f"https://{settings.MAIN_DOMAIN}/auth/signup/{self.invite.token}/",
} }
def form_valid(self, form): def form_valid(self, form):
expires_days = form.cleaned_data.get("expires_days")
self.invite.note = form.cleaned_data.get("notes") or "" self.invite.note = form.cleaned_data.get("notes") or ""
self.invite.email = form.cleaned_data.get("email") or None self.invite.uses = form.cleaned_data.get("uses") or None
self.invite.expires = (
timezone.now() + datetime.timedelta(days=expires_days)
if expires_days is not None
else None
)
self.invite.save() self.invite.save()
return redirect(self.invite.urls.admin) return redirect(self.invite.urls.admin)

View File

@ -69,11 +69,7 @@ class BasicSettings(AdminSettingsPage):
}, },
"signup_allowed": { "signup_allowed": {
"title": "Signups Allowed", "title": "Signups Allowed",
"help_text": "If signups are allowed at all", "help_text": "If uninvited signups are allowed.\nInvite codes will always work.",
},
"signup_invite_only": {
"title": "Invite-Only",
"help_text": "If signups require an invite code",
}, },
"signup_text": { "signup_text": {
"title": "Signup Page Text", "title": "Signup Page Text",
@ -103,7 +99,10 @@ class BasicSettings(AdminSettingsPage):
"site_banner", "site_banner",
"highlight_color", "highlight_color",
], ],
"Signups": ["signup_allowed", "signup_invite_only", "signup_text"], "Signups": [
"signup_allowed",
"signup_text",
],
"Posts": [ "Posts": [
"post_length", "post_length",
"post_minimum_interval", "post_minimum_interval",

View File

@ -4,6 +4,7 @@ from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.views import LoginView, LogoutView from django.contrib.auth.views import LoginView, LogoutView
from django.http import Http404
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -39,11 +40,6 @@ class Signup(FormView):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Add the invite field if it's enabled
if Config.system.signup_invite_only:
self.fields["invite_code"] = forms.CharField(
help_text="Your invite code from one of our admins"
)
# Add the policies if they're defined # Add the policies if they're defined
policies = [] policies = []
if Config.system.policy_rules: if Config.system.policy_rules:
@ -82,30 +78,33 @@ class Signup(FormView):
raise forms.ValidationError("This email already has an account") raise forms.ValidationError("This email already has an account")
return email return email
def clean_invite_code(self): def dispatch(self, request, token=None, *args, **kwargs):
invite_code = self.cleaned_data["invite_code"].lower().strip() if token:
invite = Invite.objects.filter(token=invite_code).first() self.invite = get_object_or_404(Invite, token=token)
if not invite: if not self.invite.valid:
raise forms.ValidationError("That is not a valid invite code") raise Http404()
if invite.email and invite.email != self.cleaned_data.get("email"): else:
raise forms.ValidationError( self.invite = None
"That is not a valid invite code for this email address" return super().dispatch(request, *args, **kwargs)
)
return invite_code
def clean(self):
if not Config.system.signup_allowed:
raise forms.ValidationError("Not accepting new users at this time")
def form_valid(self, form): def form_valid(self, form):
# Don't allow anything if there's no invite and no signup allowed
if not Config.system.signup_allowed and not self.invite:
return self.render_to_response(self.get_context_data())
# Make the new user
user = User.objects.create(email=form.cleaned_data["email"]) user = User.objects.create(email=form.cleaned_data["email"])
# Auto-promote the user to admin if that setting is set # Auto-promote the user to admin if that setting is set
if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL: if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL:
user.admin = True user.admin = True
user.save() user.save()
PasswordReset.create_for_user(user) PasswordReset.create_for_user(user)
if "invite_code" in form.cleaned_data: # Drop invite uses down if it has them
Invite.objects.filter(token=form.cleaned_data["invite_code"]).delete() if self.invite and self.invite.uses is not None:
self.invite.uses -= 1
if self.invite.uses <= 0:
self.invite.delete()
else:
self.invite.save()
return render( return render(
self.request, self.request,
"auth/signup_success.html", "auth/signup_success.html",
@ -114,6 +113,8 @@ class Signup(FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if not Config.system.signup_allowed and not self.invite:
del context["form"]
if Config.system.signup_text: if Config.system.signup_text:
context["signup_text"] = mark_safe( context["signup_text"] = mark_safe(
markdown_it.MarkdownIt().render(Config.system.signup_text) markdown_it.MarkdownIt().render(Config.system.signup_text)