From 1b44a253316a84f40070264ea8134c86d1223441 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 18 Nov 2022 00:09:04 -0700 Subject: Signup and invite tweaks --- users/admin.py | 6 + users/migrations/0005_invite.py | 32 ++++++ users/models/__init__.py | 1 + users/models/invite.py | 30 +++++ users/models/password_reset.py | 2 +- users/views/admin.py | 248 ---------------------------------------- users/views/admin/__init__.py | 56 +++++++++ users/views/admin/domains.py | 153 +++++++++++++++++++++++++ users/views/admin/settings.py | 83 ++++++++++++++ users/views/auth.py | 18 ++- 10 files changed, 379 insertions(+), 250 deletions(-) create mode 100644 users/migrations/0005_invite.py create mode 100644 users/models/invite.py delete mode 100644 users/views/admin.py create mode 100644 users/views/admin/__init__.py create mode 100644 users/views/admin/domains.py create mode 100644 users/views/admin/settings.py (limited to 'users') diff --git a/users/admin.py b/users/admin.py index de07e5c..0901307 100644 --- a/users/admin.py +++ b/users/admin.py @@ -5,6 +5,7 @@ from users.models import ( Follow, Identity, InboxMessage, + Invite, PasswordReset, User, UserEvent, @@ -66,3 +67,8 @@ class InboxMessageAdmin(admin.ModelAdmin): def reset_state(self, request, queryset): for instance in queryset: instance.transition_perform("received") + + +@admin.register(Invite) +class InviteAdmin(admin.ModelAdmin): + list_display = ["id", "created", "token", "note"] diff --git a/users/migrations/0005_invite.py b/users/migrations/0005_invite.py new file mode 100644 index 0000000..bb18841 --- /dev/null +++ b/users/migrations/0005_invite.py @@ -0,0 +1,32 @@ +# Generated by Django 4.1.3 on 2022-11-18 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0004_passwordreset"), + ] + + operations = [ + migrations.CreateModel( + name="Invite", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token", models.CharField(max_length=500, unique=True)), + ("email", models.EmailField(blank=True, max_length=254, null=True)), + ("note", models.TextField(blank=True, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index e46860e..fc0d402 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -3,6 +3,7 @@ from .domain import Domain # noqa from .follow import Follow, FollowStates # noqa from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa +from .invite import Invite # noqa from .password_reset import PasswordReset # noqa from .user import User # noqa from .user_event import UserEvent # noqa diff --git a/users/models/invite.py b/users/models/invite.py new file mode 100644 index 0000000..5d69b18 --- /dev/null +++ b/users/models/invite.py @@ -0,0 +1,30 @@ +import random + +from django.db import models + + +class Invite(models.Model): + """ + An invite token, good for one signup. + """ + + # Should always be lowercase + 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 + note = models.TextField(null=True, blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + @classmethod + def create_random(cls, email=None): + return cls.objects.create( + token="".join( + random.choice("abcdefghkmnpqrstuvwxyz23456789") for i in range(20) + ), + email=email, + ) diff --git a/users/models/password_reset.py b/users/models/password_reset.py index 90062d3..628efa6 100644 --- a/users/models/password_reset.py +++ b/users/models/password_reset.py @@ -27,7 +27,7 @@ class PasswordResetStates(StateGraph): await sync_to_async(send_mail)( subject=f"{Config.system.site_name}: Confirm new account", message=render_to_string( - "emails/new_account.txt", + "emails/account_new.txt", { "reset": reset, "config": Config.system, diff --git a/users/views/admin.py b/users/views/admin.py deleted file mode 100644 index 93bf4ec..0000000 --- a/users/views/admin.py +++ /dev/null @@ -1,248 +0,0 @@ -import re - -from django import forms -from django.db import models -from django.shortcuts import get_object_or_404, redirect -from django.utils.decorators import method_decorator -from django.views.generic import FormView, RedirectView, TemplateView - -from core.models import Config -from users.decorators import admin_required -from users.models import Domain, Identity, User -from users.views.settings import SettingsPage - - -@method_decorator(admin_required, name="dispatch") -class AdminRoot(RedirectView): - pattern_name = "admin_basic" - - -@method_decorator(admin_required, name="dispatch") -class AdminSettingsPage(SettingsPage): - """ - Shows a settings page dynamically created from our settings layout - at the bottom of the page. Don't add this to a URL directly - subclass! - """ - - options_class = Config.SystemOptions - - def load_config(self): - return Config.load_system() - - def save_config(self, key, value): - Config.set_system(key, value) - - -class BasicPage(AdminSettingsPage): - - section = "basic" - - options = { - "site_name": { - "title": "Site Name", - }, - "highlight_color": { - "title": "Highlight Color", - "help_text": "Used for logo background and other highlights", - }, - "post_length": { - "title": "Maximum Post Length", - "help_text": "The maximum number of characters allowed per post", - }, - "site_about": { - "title": "About This Site", - "help_text": "Displayed on the homepage and the about page", - "display": "textarea", - }, - "site_icon": { - "title": "Site Icon", - "help_text": "Minimum size 64x64px. Should be square.", - }, - "site_banner": { - "title": "Site Banner", - "help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.", - }, - "identity_max_per_user": { - "title": "Maximum Identities Per User", - "help_text": "Non-admins will be blocked from creating more than this", - }, - } - - layout = { - "Branding": [ - "site_name", - "site_about", - "site_icon", - "site_banner", - "highlight_color", - ], - "Posts": ["post_length"], - "Identities": ["identity_max_per_user"], - } - - -@method_decorator(admin_required, name="dispatch") -class DomainsPage(TemplateView): - - template_name = "admin/domains.html" - - def get_context_data(self): - return { - "domains": Domain.objects.filter(local=True).order_by("domain"), - "section": "domains", - } - - -@method_decorator(admin_required, name="dispatch") -class DomainCreatePage(FormView): - - template_name = "admin/domain_create.html" - extra_context = {"section": "domains"} - - class form_class(forms.Form): - domain = forms.CharField( - help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", - ) - service_domain = forms.CharField( - help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", - required=False, - ) - public = forms.BooleanField( - help_text="If any user on this server can create identities here", - widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), - required=False, - ) - - domain_regex = re.compile( - r"^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$" - ) - - def clean_domain(self): - if not self.domain_regex.match(self.cleaned_data["domain"]): - raise forms.ValidationError("This does not look like a domain name") - if Domain.objects.filter( - models.Q(domain=self.cleaned_data["domain"]) - | models.Q(service_domain=self.cleaned_data["domain"]) - ): - raise forms.ValidationError("This domain name is already in use") - return self.cleaned_data["domain"] - - def clean_service_domain(self): - if not self.cleaned_data["service_domain"]: - return None - if not self.domain_regex.match(self.cleaned_data["service_domain"]): - raise forms.ValidationError("This does not look like a domain name") - if Domain.objects.filter( - models.Q(domain=self.cleaned_data["service_domain"]) - | models.Q(service_domain=self.cleaned_data["service_domain"]) - ): - raise forms.ValidationError("This domain name is already in use") - if self.cleaned_data.get("domain") == self.cleaned_data["service_domain"]: - raise forms.ValidationError( - "You cannot have the domain and service domain be the same (did you mean to leave service domain blank?)" - ) - return self.cleaned_data["service_domain"] - - def form_valid(self, form): - Domain.objects.create( - domain=form.cleaned_data["domain"], - service_domain=form.cleaned_data["service_domain"] or None, - public=form.cleaned_data["public"], - local=True, - ) - return redirect(Domain.urls.root) - - -@method_decorator(admin_required, name="dispatch") -class DomainEditPage(FormView): - - template_name = "admin/domain_edit.html" - extra_context = {"section": "domains"} - - class form_class(forms.Form): - domain = forms.CharField( - help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", - disabled=True, - ) - service_domain = forms.CharField( - help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", - disabled=True, - required=False, - ) - public = forms.BooleanField( - help_text="If any user on this server can create identities here", - widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), - required=False, - ) - - def dispatch(self, request, domain): - self.domain = get_object_or_404( - Domain.objects.filter(local=True), domain=domain - ) - return super().dispatch(request) - - def get_context_data(self): - context = super().get_context_data() - context["domain"] = self.domain - return context - - def form_valid(self, form): - self.domain.public = form.cleaned_data["public"] - self.domain.save() - return redirect(Domain.urls.root) - - def get_initial(self): - return { - "domain": self.domain.domain, - "service_domain": self.domain.service_domain, - "public": self.domain.public, - } - - -@method_decorator(admin_required, name="dispatch") -class DomainDeletePage(TemplateView): - - template_name = "admin/domain_delete.html" - - def dispatch(self, request, domain): - self.domain = get_object_or_404( - Domain.objects.filter(public=True), domain=domain - ) - return super().dispatch(request) - - def get_context_data(self): - return { - "domain": self.domain, - "num_identities": self.domain.identities.count(), - "section": "domains", - } - - def post(self, request): - if self.domain.identities.exists(): - raise ValueError("Tried to delete domain with identities!") - self.domain.delete() - return redirect("/settings/system/domains/") - - -@method_decorator(admin_required, name="dispatch") -class UsersPage(TemplateView): - - template_name = "admin/users.html" - - def get_context_data(self): - return { - "users": User.objects.order_by("email"), - "section": "users", - } - - -@method_decorator(admin_required, name="dispatch") -class IdentitiesPage(TemplateView): - - template_name = "admin/identities.html" - - def get_context_data(self): - return { - "identities": Identity.objects.order_by("username"), - "section": "identities", - } diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py new file mode 100644 index 0000000..231e027 --- /dev/null +++ b/users/views/admin/__init__.py @@ -0,0 +1,56 @@ +from django import forms +from django.utils.decorators import method_decorator +from django.views.generic import FormView, RedirectView, TemplateView + +from users.decorators import admin_required +from users.models import Identity, User +from users.views.admin.domains import ( # noqa + DomainCreate, + DomainDelete, + DomainEdit, + Domains, +) +from users.views.admin.settings import BasicSettings # noqa + + +@method_decorator(admin_required, name="dispatch") +class AdminRoot(RedirectView): + pattern_name = "admin_basic" + + +@method_decorator(admin_required, name="dispatch") +class Users(TemplateView): + + template_name = "admin/users.html" + + def get_context_data(self): + return { + "users": User.objects.order_by("email"), + "section": "users", + } + + +@method_decorator(admin_required, name="dispatch") +class Identities(TemplateView): + + template_name = "admin/identities.html" + + def get_context_data(self): + return { + "identities": Identity.objects.order_by("username"), + "section": "identities", + } + + +@method_decorator(admin_required, name="dispatch") +class Invites(FormView): + + template_name = "admin/invites.html" + extra_context = {"section": "invites"} + + class form_class(forms.Form): + note = forms.CharField() + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + return context diff --git a/users/views/admin/domains.py b/users/views/admin/domains.py new file mode 100644 index 0000000..e1a011b --- /dev/null +++ b/users/views/admin/domains.py @@ -0,0 +1,153 @@ +import re + +from django import forms +from django.db import models +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views.generic import FormView, TemplateView + +from users.decorators import admin_required +from users.models import Domain + + +@method_decorator(admin_required, name="dispatch") +class Domains(TemplateView): + + template_name = "admin/domains.html" + + def get_context_data(self): + return { + "domains": Domain.objects.filter(local=True).order_by("domain"), + "section": "domains", + } + + +@method_decorator(admin_required, name="dispatch") +class DomainCreate(FormView): + + template_name = "admin/domain_create.html" + extra_context = {"section": "domains"} + + class form_class(forms.Form): + domain = forms.CharField( + help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", + ) + service_domain = forms.CharField( + help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", + required=False, + ) + public = forms.BooleanField( + help_text="If any user on this server can create identities here", + widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), + required=False, + ) + + domain_regex = re.compile( + r"^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$" + ) + + def clean_domain(self): + if not self.domain_regex.match(self.cleaned_data["domain"]): + raise forms.ValidationError("This does not look like a domain name") + if Domain.objects.filter( + models.Q(domain=self.cleaned_data["domain"]) + | models.Q(service_domain=self.cleaned_data["domain"]) + ): + raise forms.ValidationError("This domain name is already in use") + return self.cleaned_data["domain"] + + def clean_service_domain(self): + if not self.cleaned_data["service_domain"]: + return None + if not self.domain_regex.match(self.cleaned_data["service_domain"]): + raise forms.ValidationError("This does not look like a domain name") + if Domain.objects.filter( + models.Q(domain=self.cleaned_data["service_domain"]) + | models.Q(service_domain=self.cleaned_data["service_domain"]) + ): + raise forms.ValidationError("This domain name is already in use") + if self.cleaned_data.get("domain") == self.cleaned_data["service_domain"]: + raise forms.ValidationError( + "You cannot have the domain and service domain be the same (did you mean to leave service domain blank?)" + ) + return self.cleaned_data["service_domain"] + + def form_valid(self, form): + Domain.objects.create( + domain=form.cleaned_data["domain"], + service_domain=form.cleaned_data["service_domain"] or None, + public=form.cleaned_data["public"], + local=True, + ) + return redirect(Domain.urls.root) + + +@method_decorator(admin_required, name="dispatch") +class DomainEdit(FormView): + + template_name = "admin/domain_edit.html" + extra_context = {"section": "domains"} + + class form_class(forms.Form): + domain = forms.CharField( + help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", + disabled=True, + ) + service_domain = forms.CharField( + help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", + disabled=True, + required=False, + ) + public = forms.BooleanField( + help_text="If any user on this server can create identities here", + widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), + required=False, + ) + + def dispatch(self, request, domain): + self.domain = get_object_or_404( + Domain.objects.filter(local=True), domain=domain + ) + return super().dispatch(request) + + def get_context_data(self): + context = super().get_context_data() + context["domain"] = self.domain + return context + + def form_valid(self, form): + self.domain.public = form.cleaned_data["public"] + self.domain.save() + return redirect(Domain.urls.root) + + def get_initial(self): + return { + "domain": self.domain.domain, + "service_domain": self.domain.service_domain, + "public": self.domain.public, + } + + +@method_decorator(admin_required, name="dispatch") +class DomainDelete(TemplateView): + + template_name = "admin/domain_delete.html" + + def dispatch(self, request, domain): + self.domain = get_object_or_404( + Domain.objects.filter(public=True), domain=domain + ) + return super().dispatch(request) + + def get_context_data(self): + return { + "domain": self.domain, + "num_identities": self.domain.identities.count(), + "section": "domains", + } + + def post(self, request): + if self.domain.identities.exists(): + raise ValueError("Tried to delete domain with identities!") + self.domain.delete() + return redirect("/settings/system/domains/") diff --git a/users/views/admin/settings.py b/users/views/admin/settings.py new file mode 100644 index 0000000..a528f93 --- /dev/null +++ b/users/views/admin/settings.py @@ -0,0 +1,83 @@ +from django.utils.decorators import method_decorator + +from core.models import Config +from users.decorators import admin_required +from users.views.settings import SettingsPage + + +@method_decorator(admin_required, name="dispatch") +class AdminSettingsPage(SettingsPage): + """ + Shows a settings page dynamically created from our settings layout + at the bottom of the page. Don't add this to a URL directly - subclass! + """ + + options_class = Config.SystemOptions + + def load_config(self): + return Config.load_system() + + def save_config(self, key, value): + Config.set_system(key, value) + + +class BasicSettings(AdminSettingsPage): + + section = "basic" + + options = { + "site_name": { + "title": "Site Name", + }, + "highlight_color": { + "title": "Highlight Color", + "help_text": "Used for logo background and other highlights", + }, + "post_length": { + "title": "Maximum Post Length", + "help_text": "The maximum number of characters allowed per post", + }, + "site_about": { + "title": "About This Site", + "help_text": "Displayed on the homepage and the about page", + "display": "textarea", + }, + "site_icon": { + "title": "Site Icon", + "help_text": "Minimum size 64x64px. Should be square.", + }, + "site_banner": { + "title": "Site Banner", + "help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.", + }, + "identity_max_per_user": { + "title": "Maximum Identities Per User", + "help_text": "Non-admins will be blocked from creating more than this", + }, + "signup_allowed": { + "title": "Signups Allowed", + "help_text": "If signups are allowed at all", + }, + "signup_invite_only": { + "title": "Invite-Only", + "help_text": "If signups require an invite code", + }, + "signup_text": { + "title": "Signup Page Text", + "help_text": "Shown above the signup form", + "display": "textarea", + }, + } + + layout = { + "Branding": [ + "site_name", + "site_about", + "site_icon", + "site_banner", + "highlight_color", + ], + "Signups": ["signup_allowed", "signup_invite_only", "signup_text"], + "Posts": ["post_length"], + "Identities": ["identity_max_per_user"], + } diff --git a/users/views/auth.py b/users/views/auth.py index 7f51d45..a04b1b1 100644 --- a/users/views/auth.py +++ b/users/views/auth.py @@ -4,7 +4,8 @@ from django.contrib.auth.views import LoginView, LogoutView from django.shortcuts import get_object_or_404, render from django.views.generic import FormView -from users.models import PasswordReset, User +from core.models import Config +from users.models import Invite, PasswordReset, User class Login(LoginView): @@ -26,6 +27,13 @@ class Signup(FormView): help_text="We will send a link to this email to set your password and create your account", ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if Config.system.signup_invite_only: + self.fields["invite_code"] = forms.CharField( + help_text="Your invite code from one of our admins" + ) + def clean_email(self): email = self.cleaned_data.get("email").lower() if not email: @@ -34,9 +42,17 @@ class Signup(FormView): raise forms.ValidationError("This email already has an account") return email + def clean_invite_code(self): + invite_code = self.cleaned_data["invite_code"].lower().strip() + if not Invite.objects.filter(token=invite_code).exists(): + raise forms.ValidationError("That is not a valid invite code") + return invite_code + def form_valid(self, form): user = User.objects.create(email=form.cleaned_data["email"]) PasswordReset.create_for_user(user) + if "invite_code" in form.cleaned_data: + Invite.objects.filter(token=form.cleaned_data["invite_code"]).delete() return render( self.request, "auth/signup_success.html", -- cgit v1.2.3