summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-18 00:09:04 -0700
committerAndrew Godwin2022-11-18 00:09:04 -0700
commit1b44a253316a84f40070264ea8134c86d1223441 (patch)
tree2caa56d54a8e81f14649b826f9f3ef5a7c1326ae
parentb3072c81ba73a16381366960841b6c294cc1fa6e (diff)
downloadtakahe-1b44a253316a84f40070264ea8134c86d1223441.tar.gz
takahe-1b44a253316a84f40070264ea8134c86d1223441.tar.bz2
takahe-1b44a253316a84f40070264ea8134c86d1223441.zip
Signup and invite tweaks
-rw-r--r--README.md4
-rw-r--r--core/middleware.py17
-rw-r--r--core/models/config.py4
-rw-r--r--takahe/settings/base.py1
-rw-r--r--takahe/urls.py19
-rw-r--r--templates/activities/_menu.html8
-rw-r--r--templates/admin/invites.html9
-rw-r--r--templates/auth/signup.html1
-rw-r--r--templates/emails/account_new.txt (renamed from templates/emails/new_account.txt)0
-rw-r--r--templates/settings/_menu.html3
-rw-r--r--users/admin.py6
-rw-r--r--users/migrations/0005_invite.py32
-rw-r--r--users/models/__init__.py1
-rw-r--r--users/models/invite.py30
-rw-r--r--users/models/password_reset.py2
-rw-r--r--users/views/admin/__init__.py56
-rw-r--r--users/views/admin/domains.py (renamed from users/views/admin.py)107
-rw-r--r--users/views/admin/settings.py83
-rw-r--r--users/views/auth.py18
19 files changed, 285 insertions, 116 deletions
diff --git a/README.md b/README.md
index 4236eb1..1b3bcb2 100644
--- a/README.md
+++ b/README.md
@@ -61,8 +61,8 @@ the less sure I am about it.
- [x] Profile pages
- [x] Settable icon and background image for profiles
- [x] User search
-- [ ] Following page
-- [ ] Followers page
+- [x] Following page
+- [x] Followers page
- [x] Multiple domain support
- [x] Multiple identity support
- [x] Serverless-friendly worker subsystem
diff --git a/core/middleware.py b/core/middleware.py
index 8e95f06..fdb08a8 100644
--- a/core/middleware.py
+++ b/core/middleware.py
@@ -1,3 +1,6 @@
+from core.models import Config
+
+
class AlwaysSecureMiddleware:
"""
Locks the request object as always being secure, for when it's behind
@@ -11,3 +14,17 @@ class AlwaysSecureMiddleware:
request.__class__.scheme = "https"
response = self.get_response(request)
return response
+
+
+class ConfigLoadingMiddleware:
+ """
+ Caches the system config every request
+ """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ Config.system = Config.load_system()
+ response = self.get_response(request)
+ return response
diff --git a/core/models/config.py b/core/models/config.py
index 4ba8375..2a27d2e 100644
--- a/core/models/config.py
+++ b/core/models/config.py
@@ -160,7 +160,9 @@ class Config(models.Model):
site_icon: UploadedImage = static("img/icon-128.png")
site_banner: UploadedImage = static("img/fjords-banner-600.jpg")
- allow_signups: bool = False
+ signup_allowed: bool = False
+ signup_invite_only: bool = False
+ signup_text: str = ""
post_length: int = 500
identity_max_per_user: int = 5
diff --git a/takahe/settings/base.py b/takahe/settings/base.py
index 04a43dd..614bfd2 100644
--- a/takahe/settings/base.py
+++ b/takahe/settings/base.py
@@ -28,6 +28,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
+ "core.middleware.ConfigLoadingMiddleware",
"users.middleware.IdentityMiddleware",
]
diff --git a/takahe/urls.py b/takahe/urls.py
index 044599a..8c01d64 100644
--- a/takahe/urls.py
+++ b/takahe/urls.py
@@ -48,37 +48,42 @@ urlpatterns = [
),
path(
"admin/basic/",
- admin.BasicPage.as_view(),
+ admin.BasicSettings.as_view(),
name="admin_basic",
),
path(
"admin/domains/",
- admin.DomainsPage.as_view(),
+ admin.Domains.as_view(),
name="admin_domains",
),
path(
"admin/domains/create/",
- admin.DomainCreatePage.as_view(),
+ admin.DomainCreate.as_view(),
name="admin_domains_create",
),
path(
"admin/domains/<domain>/",
- admin.DomainEditPage.as_view(),
+ admin.DomainEdit.as_view(),
),
path(
"admin/domains/<domain>/delete/",
- admin.DomainDeletePage.as_view(),
+ admin.DomainDelete.as_view(),
),
path(
"admin/users/",
- admin.UsersPage.as_view(),
+ admin.Users.as_view(),
name="admin_users",
),
path(
"admin/identities/",
- admin.IdentitiesPage.as_view(),
+ admin.Identities.as_view(),
name="admin_identities",
),
+ path(
+ "admin/invites/",
+ admin.Invites.as_view(),
+ name="admin_invites",
+ ),
# Identity views
path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/actor/", activitypub.Actor.as_view()),
diff --git a/templates/activities/_menu.html b/templates/activities/_menu.html
index 6b40197..01f34cd 100644
--- a/templates/activities/_menu.html
+++ b/templates/activities/_menu.html
@@ -27,9 +27,11 @@
<i class="fa-solid fa-city"></i> Local Posts
</a>
<h3></h3>
- <a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %}>
- <i class="fa-solid fa-user-plus"></i> Create Account
- </a>
+ {% if config.signup_allowed %}
+ <a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %}>
+ <i class="fa-solid fa-user-plus"></i> Create Account
+ </a>
+ {% endif %}
{% endif %}
</nav>
diff --git a/templates/admin/invites.html b/templates/admin/invites.html
new file mode 100644
index 0000000..14e05f3
--- /dev/null
+++ b/templates/admin/invites.html
@@ -0,0 +1,9 @@
+{% extends "settings/base.html" %}
+
+{% block subtitle %}Invites{% endblock %}
+
+{% block content %}
+ <p>
+ Please use the <a href="/djadmin/users/invite/">Django Admin</a> for now.
+ </p>
+{% endblock %}
diff --git a/templates/auth/signup.html b/templates/auth/signup.html
index d519476..b1aaa50 100644
--- a/templates/auth/signup.html
+++ b/templates/auth/signup.html
@@ -7,6 +7,7 @@
{% csrf_token %}
<fieldset>
<legend>Create An Account</legend>
+ {{ config.signup_text|safe|linebreaks }}
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
diff --git a/templates/emails/new_account.txt b/templates/emails/account_new.txt
index 73c7fa4..73c7fa4 100644
--- a/templates/emails/new_account.txt
+++ b/templates/emails/account_new.txt
diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html
index dd43912..21bc048 100644
--- a/templates/settings/_menu.html
+++ b/templates/settings/_menu.html
@@ -30,6 +30,9 @@
<a href="{% url "admin_identities" %}" {% if section == "identities" %}class="selected"{% endif %}>
<i class="fa-solid fa-id-card"></i> Identities
</a>
+ <a href="{% url "admin_invites" %}" {% if section == "invites" %}class="selected"{% endif %}>
+ <i class="fa-solid fa-envelope"></i> Invites
+ </a>
<a href="/djadmin">
<i class="fa-solid fa-gear"></i> Django Admin
</a>
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/__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.py b/users/views/admin/domains.py
index 93bf4ec..e1a011b 100644
--- a/users/views/admin.py
+++ b/users/views/admin/domains.py
@@ -4,85 +4,14 @@ 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 django.views.generic import FormView, 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
+from users.models import Domain
@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):
+class Domains(TemplateView):
template_name = "admin/domains.html"
@@ -94,7 +23,7 @@ class DomainsPage(TemplateView):
@method_decorator(admin_required, name="dispatch")
-class DomainCreatePage(FormView):
+class DomainCreate(FormView):
template_name = "admin/domain_create.html"
extra_context = {"section": "domains"}
@@ -154,7 +83,7 @@ class DomainCreatePage(FormView):
@method_decorator(admin_required, name="dispatch")
-class DomainEditPage(FormView):
+class DomainEdit(FormView):
template_name = "admin/domain_edit.html"
extra_context = {"section": "domains"}
@@ -200,7 +129,7 @@ class DomainEditPage(FormView):
@method_decorator(admin_required, name="dispatch")
-class DomainDeletePage(TemplateView):
+class DomainDelete(TemplateView):
template_name = "admin/domain_delete.html"
@@ -222,27 +151,3 @@ class DomainDeletePage(TemplateView):
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/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",