Add signup and password reset
This commit is contained in:
		
							parent
							
								
									2a3690d1c1
								
							
						
					
					
						commit
						6adfdbabe0
					
				
							
								
								
									
										28
									
								
								activities/migrations/0009_alter_postattachment_file.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								activities/migrations/0009_alter_postattachment_file.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
			
		||||
# Generated by Django 4.1.3 on 2022-11-18 01:40
 | 
			
		||||
 | 
			
		||||
import functools
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
import core.uploads
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("activities", "0008_postattachment"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="postattachment",
 | 
			
		||||
            name="file",
 | 
			
		||||
            field=models.FileField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                null=True,
 | 
			
		||||
                upload_to=functools.partial(
 | 
			
		||||
                    core.uploads.upload_namer, *("attachments",), **{}
 | 
			
		||||
                ),
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -9,4 +9,7 @@ class CoreConfig(AppConfig):
 | 
			
		||||
    name = "core"
 | 
			
		||||
 | 
			
		||||
    def ready(self) -> None:
 | 
			
		||||
        from core.models import Config
 | 
			
		||||
 | 
			
		||||
        Config.system = Config.load_system()
 | 
			
		||||
        jsonld.set_document_loader(builtin_document_loader)
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ from core.models import Config
 | 
			
		||||
 | 
			
		||||
def config_context(request):
 | 
			
		||||
    return {
 | 
			
		||||
        "config": Config.load_system(),
 | 
			
		||||
        "config": Config.system,
 | 
			
		||||
        "config_identity": (
 | 
			
		||||
            Config.load_identity(request.identity) if request.identity else None
 | 
			
		||||
        ),
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										28
									
								
								core/migrations/0002_alter_config_image.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								core/migrations/0002_alter_config_image.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
			
		||||
# Generated by Django 4.1.3 on 2022-11-18 01:40
 | 
			
		||||
 | 
			
		||||
import functools
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
import core.uploads
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("core", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="config",
 | 
			
		||||
            name="image",
 | 
			
		||||
            field=models.ImageField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                null=True,
 | 
			
		||||
                upload_to=functools.partial(
 | 
			
		||||
                    core.uploads.upload_namer, *("config",), **{}
 | 
			
		||||
                ),
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -5,7 +5,6 @@ import pydantic
 | 
			
		||||
from django.core.files import File
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.templatetags.static import static
 | 
			
		||||
from django.utils.functional import classproperty
 | 
			
		||||
 | 
			
		||||
from core.uploads import upload_namer
 | 
			
		||||
from takahe import __version__
 | 
			
		||||
@ -54,11 +53,6 @@ class Config(models.Model):
 | 
			
		||||
            ("key", "user", "identity"),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    @classproperty
 | 
			
		||||
    def system(cls):
 | 
			
		||||
        cls.system = cls.load_system()
 | 
			
		||||
        return cls.system
 | 
			
		||||
 | 
			
		||||
    system: ClassVar["Config.ConfigOptions"]  # type: ignore
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
@ -160,13 +154,16 @@ class Config(models.Model):
 | 
			
		||||
 | 
			
		||||
        version: str = __version__
 | 
			
		||||
 | 
			
		||||
        site_name: str = "takahē"
 | 
			
		||||
        site_name: str = "Takahē"
 | 
			
		||||
        highlight_color: str = "#449c8c"
 | 
			
		||||
        site_about: str = "<h2>Welcome!</h2>\n\nThis is a community running Takahē."
 | 
			
		||||
        site_icon: UploadedImage = static("img/icon-128.png")
 | 
			
		||||
        site_banner: UploadedImage = static("img/fjords-banner-600.jpg")
 | 
			
		||||
 | 
			
		||||
        allow_signups: bool = False
 | 
			
		||||
 | 
			
		||||
        post_length: int = 500
 | 
			
		||||
        identity_max_per_user: int = 5
 | 
			
		||||
        identity_max_age: int = 24 * 60 * 60
 | 
			
		||||
 | 
			
		||||
    class UserOptions(pydantic.BaseModel):
 | 
			
		||||
 | 
			
		||||
@ -136,6 +136,7 @@ header .logo {
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
    background: var(--color-highlight);
 | 
			
		||||
    border-radius: 5px 0 0 0;
 | 
			
		||||
    text-transform: lowercase;
 | 
			
		||||
    padding: 10px 11px 9px 10px;
 | 
			
		||||
    height: 50px;
 | 
			
		||||
    font-size: 130%;
 | 
			
		||||
@ -198,6 +199,12 @@ header menu a.identity {
 | 
			
		||||
    width: 250px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
header menu a.identity i {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
    padding: 0 7px 2px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
header menu a img {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
@ -287,7 +294,7 @@ nav a i {
 | 
			
		||||
 | 
			
		||||
/* Icon menus */
 | 
			
		||||
 | 
			
		||||
.icon-menu>a {
 | 
			
		||||
.icon-menu .option {
 | 
			
		||||
    display: block;
 | 
			
		||||
    margin: 0px 0 20px 0;
 | 
			
		||||
    background: var(--color-bg-box);
 | 
			
		||||
@ -299,19 +306,28 @@ nav a i {
 | 
			
		||||
    border-radius: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon-menu>a:hover {
 | 
			
		||||
.icon-menu .option:hover {
 | 
			
		||||
    border: 2px solid var(--color-highlight);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon-menu>a img,
 | 
			
		||||
.icon-menu>a i {
 | 
			
		||||
.icon-menu .option.empty {
 | 
			
		||||
    color: var(--color-text-dull);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon-menu .option.empty:hover {
 | 
			
		||||
    border: 0;
 | 
			
		||||
    border: 2px solid rgba(255, 255, 255, 0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon-menu .option img,
 | 
			
		||||
.icon-menu .option i {
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
    margin: 0 10px 3px 0;
 | 
			
		||||
    height: 50px;
 | 
			
		||||
    width: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon-menu>a i {
 | 
			
		||||
.icon-menu .option i {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    width: 50px;
 | 
			
		||||
 | 
			
		||||
@ -108,6 +108,11 @@ STATICFILES_DIRS = [
 | 
			
		||||
 | 
			
		||||
ALLOWED_HOSTS = ["*"]
 | 
			
		||||
 | 
			
		||||
MAIN_DOMAIN = os.environ["TAKAHE_MAIN_DOMAIN"]
 | 
			
		||||
if "/" in MAIN_DOMAIN:
 | 
			
		||||
    print("TAKAHE_MAIN_DOMAIN should be just the domain name - no https:// or path")
 | 
			
		||||
 | 
			
		||||
EMAIL_FROM = os.environ["TAKAHE_EMAIL_FROM"]
 | 
			
		||||
 | 
			
		||||
# Note that this MUST be a fully qualified URL in production
 | 
			
		||||
MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/")
 | 
			
		||||
 | 
			
		||||
@ -16,3 +16,5 @@ CSRF_TRUSTED_ORIGINS = [
 | 
			
		||||
    "http://127.0.0.1:8000",
 | 
			
		||||
    "https://127.0.0.1:8000",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
 | 
			
		||||
 | 
			
		||||
@ -82,8 +82,10 @@ urlpatterns = [
 | 
			
		||||
    path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
 | 
			
		||||
    path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
 | 
			
		||||
    # Authentication
 | 
			
		||||
    path("auth/login/", auth.Login.as_view()),
 | 
			
		||||
    path("auth/logout/", auth.Logout.as_view()),
 | 
			
		||||
    path("auth/login/", auth.Login.as_view(), name="login"),
 | 
			
		||||
    path("auth/logout/", auth.Logout.as_view(), name="logout"),
 | 
			
		||||
    path("auth/signup/", auth.Signup.as_view(), name="signup"),
 | 
			
		||||
    path("auth/reset/<token>/", auth.Reset.as_view(), name="password_reset"),
 | 
			
		||||
    # Identity selection
 | 
			
		||||
    path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
 | 
			
		||||
    path("identity/select/", identity.SelectIdentity.as_view()),
 | 
			
		||||
 | 
			
		||||
@ -23,11 +23,11 @@
 | 
			
		||||
            <i class="fa-solid fa-gear"></i> Settings
 | 
			
		||||
        </a>
 | 
			
		||||
    {% else %}
 | 
			
		||||
        <a href="/local/" {% if current_page == "local" %}class="selected"{% endif %}>
 | 
			
		||||
        <a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %}>
 | 
			
		||||
            <i class="fa-solid fa-city"></i> Local Posts
 | 
			
		||||
        </a>
 | 
			
		||||
        <h3></h3>
 | 
			
		||||
        <a href="/auth/signup/" {% if current_page == "signup" %}class="selected"{% endif %}>
 | 
			
		||||
        <a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %}>
 | 
			
		||||
            <i class="fa-solid fa-user-plus"></i> Create Account
 | 
			
		||||
        </a>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										20
									
								
								templates/auth/reset.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								templates/auth/reset.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}Reset Password{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form action="." method="POST">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>Reset Password</legend>
 | 
			
		||||
            <p>You are resetting your password for {{ reset.user.email }}.</p>
 | 
			
		||||
            <p>Please choose your new password below.</p>
 | 
			
		||||
            {% for field in form %}
 | 
			
		||||
                {% include "forms/_field.html" %}
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <div class="buttons">
 | 
			
		||||
            <button>Reset</button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										14
									
								
								templates/auth/reset_success.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								templates/auth/reset_success.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}Password Reset{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form>
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>Password Reset</legend>
 | 
			
		||||
            <p>
 | 
			
		||||
                Your password for <tt>{{ email }}</tt> has been reset!
 | 
			
		||||
            </p>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										18
									
								
								templates/auth/signup.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								templates/auth/signup.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}Create Account{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form action="." method="POST">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>Create An Account</legend>
 | 
			
		||||
            {% for field in form %}
 | 
			
		||||
                {% include "forms/_field.html" %}
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <div class="buttons">
 | 
			
		||||
            <button>Create</button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										15
									
								
								templates/auth/signup_success.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								templates/auth/signup_success.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}Email Sent{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form>
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>Email Sent</legend>
 | 
			
		||||
            <p>
 | 
			
		||||
                An email has been sent to <tt>{{ email }}</tt> - please follow
 | 
			
		||||
                the link inside to finish creating your account.
 | 
			
		||||
            </p>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										8
									
								
								templates/emails/new_account.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								templates/emails/new_account.txt
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
Your email address was used to create a new account at {{config.site_name}} (https://{{settings.MAIN_DOMAIN}}).
 | 
			
		||||
 | 
			
		||||
To confirm your new account, go to this link:
 | 
			
		||||
 | 
			
		||||
https://{{settings.MAIN_DOMAIN}}/auth/reset/{{reset.token}}/
 | 
			
		||||
 | 
			
		||||
If this was not you, then please ignore this message - your email will not be
 | 
			
		||||
used to make an account if this link is not visited.
 | 
			
		||||
							
								
								
									
										8
									
								
								templates/emails/password_reset.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								templates/emails/password_reset.txt
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
A password reset was requested for your account ({{reset.user.email}}) at {{Config.system.site_name}} (https://{{settings.MAIN_DOMAIN}}).
 | 
			
		||||
 | 
			
		||||
To reset your password, go to this link:
 | 
			
		||||
 | 
			
		||||
https://{{settings.MAIN_DOMAIN}}/auth/reset/{{reset.token}}/
 | 
			
		||||
 | 
			
		||||
If this was not you, then please ignore this message - your password will not be
 | 
			
		||||
reset if this link is not visited.
 | 
			
		||||
@ -1,6 +1,14 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from users.models import Domain, Follow, Identity, InboxMessage, User, UserEvent
 | 
			
		||||
from users.models import (
 | 
			
		||||
    Domain,
 | 
			
		||||
    Follow,
 | 
			
		||||
    Identity,
 | 
			
		||||
    InboxMessage,
 | 
			
		||||
    PasswordReset,
 | 
			
		||||
    User,
 | 
			
		||||
    UserEvent,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Domain)
 | 
			
		||||
@ -42,6 +50,12 @@ class FollowAdmin(admin.ModelAdmin):
 | 
			
		||||
    raw_id_fields = ["source", "target"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(PasswordReset)
 | 
			
		||||
class PasswordResetAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ["id", "user", "created"]
 | 
			
		||||
    raw_id_fields = ["user"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(InboxMessage)
 | 
			
		||||
class InboxMessageAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ["id", "state", "state_attempted", "message_type", "message_actor"]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										60
									
								
								users/migrations/0004_passwordreset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								users/migrations/0004_passwordreset.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
# Generated by Django 4.1.3 on 2022-11-18 01:40
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
import stator.models
 | 
			
		||||
import users.models.password_reset
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("users", "0003_user_last_seen_alter_identity_domain"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="PasswordReset",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.BigAutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("state_ready", models.BooleanField(default=True)),
 | 
			
		||||
                ("state_changed", models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                ("state_attempted", models.DateTimeField(blank=True, null=True)),
 | 
			
		||||
                ("state_locked_until", models.DateTimeField(blank=True, null=True)),
 | 
			
		||||
                (
 | 
			
		||||
                    "state",
 | 
			
		||||
                    stator.models.StateField(
 | 
			
		||||
                        choices=[("new", "new"), ("sent", "sent")],
 | 
			
		||||
                        default="new",
 | 
			
		||||
                        graph=users.models.password_reset.PasswordResetStates,
 | 
			
		||||
                        max_length=100,
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("token", models.CharField(max_length=500, unique=True)),
 | 
			
		||||
                ("new_account", models.BooleanField()),
 | 
			
		||||
                ("created", models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                ("updated", models.DateTimeField(auto_now=True)),
 | 
			
		||||
                (
 | 
			
		||||
                    "user",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        related_name="password_resets",
 | 
			
		||||
                        to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "abstract": False,
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -3,5 +3,6 @@ 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 .password_reset import PasswordReset  # noqa
 | 
			
		||||
from .user import User  # noqa
 | 
			
		||||
from .user_event import UserEvent  # noqa
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										92
									
								
								users/models/password_reset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								users/models/password_reset.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,92 @@
 | 
			
		||||
import random
 | 
			
		||||
import string
 | 
			
		||||
 | 
			
		||||
from asgiref.sync import sync_to_async
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.mail import send_mail
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.template.loader import render_to_string
 | 
			
		||||
 | 
			
		||||
from core.models import Config
 | 
			
		||||
from stator.models import State, StateField, StateGraph, StatorModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PasswordResetStates(StateGraph):
 | 
			
		||||
    new = State(try_interval=3)
 | 
			
		||||
    sent = State()
 | 
			
		||||
 | 
			
		||||
    new.transitions_to(sent)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def handle_new(cls, instance: "PasswordReset"):
 | 
			
		||||
        """
 | 
			
		||||
        Sends the password reset email.
 | 
			
		||||
        """
 | 
			
		||||
        reset = await instance.afetch_full()
 | 
			
		||||
        if reset.new_account:
 | 
			
		||||
            await sync_to_async(send_mail)(
 | 
			
		||||
                subject=f"{Config.system.site_name}: Confirm new account",
 | 
			
		||||
                message=render_to_string(
 | 
			
		||||
                    "emails/new_account.txt",
 | 
			
		||||
                    {
 | 
			
		||||
                        "reset": reset,
 | 
			
		||||
                        "config": Config.system,
 | 
			
		||||
                        "settings": settings,
 | 
			
		||||
                    },
 | 
			
		||||
                ),
 | 
			
		||||
                from_email=settings.EMAIL_FROM,
 | 
			
		||||
                recipient_list=[reset.user.email],
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            await sync_to_async(send_mail)(
 | 
			
		||||
                subject=f"{Config.system.site_name}: Reset password",
 | 
			
		||||
                message=render_to_string(
 | 
			
		||||
                    "emails/password_reset.txt",
 | 
			
		||||
                    {
 | 
			
		||||
                        "reset": reset,
 | 
			
		||||
                        "config": Config.system,
 | 
			
		||||
                        "settings": settings,
 | 
			
		||||
                    },
 | 
			
		||||
                ),
 | 
			
		||||
                from_email=settings.EMAIL_FROM,
 | 
			
		||||
                recipient_list=[reset.user.email],
 | 
			
		||||
            )
 | 
			
		||||
        return cls.sent
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PasswordReset(StatorModel):
 | 
			
		||||
    """
 | 
			
		||||
    A password reset for a user (this is also how we create accounts)
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    state = StateField(PasswordResetStates)
 | 
			
		||||
 | 
			
		||||
    user = models.ForeignKey(
 | 
			
		||||
        "users.user",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        related_name="password_resets",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    token = models.CharField(max_length=500, unique=True)
 | 
			
		||||
    new_account = models.BooleanField()
 | 
			
		||||
 | 
			
		||||
    created = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    updated = models.DateTimeField(auto_now=True)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def create_for_user(cls, user):
 | 
			
		||||
        return cls.objects.create(
 | 
			
		||||
            user=user,
 | 
			
		||||
            token="".join(random.choice(string.ascii_lowercase) for i in range(42)),
 | 
			
		||||
            new_account=not user.password,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    ### Async helpers ###
 | 
			
		||||
 | 
			
		||||
    async def afetch_full(self):
 | 
			
		||||
        """
 | 
			
		||||
        Returns a version of the object with all relations pre-loaded
 | 
			
		||||
        """
 | 
			
		||||
        return await PasswordReset.objects.select_related(
 | 
			
		||||
            "user",
 | 
			
		||||
        ).aget(pk=self.pk)
 | 
			
		||||
@ -62,6 +62,10 @@ class BasicPage(AdminSettingsPage):
 | 
			
		||||
            "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 = {
 | 
			
		||||
@ -73,6 +77,7 @@ class BasicPage(AdminSettingsPage):
 | 
			
		||||
            "highlight_color",
 | 
			
		||||
        ],
 | 
			
		||||
        "Posts": ["post_length"],
 | 
			
		||||
        "Identities": ["identity_max_per_user"],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,10 @@
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.contrib.auth.password_validation import validate_password
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Login(LoginView):
 | 
			
		||||
@ -8,3 +14,78 @@ class Login(LoginView):
 | 
			
		||||
 | 
			
		||||
class Logout(LogoutView):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Signup(FormView):
 | 
			
		||||
 | 
			
		||||
    template_name = "auth/signup.html"
 | 
			
		||||
 | 
			
		||||
    class form_class(forms.Form):
 | 
			
		||||
 | 
			
		||||
        email = forms.EmailField(
 | 
			
		||||
            help_text="We will send a link to this email to set your password and create your account",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        def clean_email(self):
 | 
			
		||||
            email = self.cleaned_data.get("email").lower()
 | 
			
		||||
            if not email:
 | 
			
		||||
                return
 | 
			
		||||
            if User.objects.filter(email=email).exists():
 | 
			
		||||
                raise forms.ValidationError("This email already has an account")
 | 
			
		||||
            return email
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        user = User.objects.create(email=form.cleaned_data["email"])
 | 
			
		||||
        PasswordReset.create_for_user(user)
 | 
			
		||||
        return render(
 | 
			
		||||
            self.request,
 | 
			
		||||
            "auth/signup_success.html",
 | 
			
		||||
            {"email": user.email},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Reset(FormView):
 | 
			
		||||
 | 
			
		||||
    template_name = "auth/reset.html"
 | 
			
		||||
 | 
			
		||||
    class form_class(forms.Form):
 | 
			
		||||
 | 
			
		||||
        password = forms.CharField(
 | 
			
		||||
            widget=forms.PasswordInput,
 | 
			
		||||
            help_text="Must be at least 8 characters, and contain both letters and numbers.",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        repeat_password = forms.CharField(
 | 
			
		||||
            widget=forms.PasswordInput,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        def clean_password(self):
 | 
			
		||||
            password = self.cleaned_data["password"]
 | 
			
		||||
            validate_password(password)
 | 
			
		||||
            return password
 | 
			
		||||
 | 
			
		||||
        def clean_repeat_password(self):
 | 
			
		||||
            if self.cleaned_data.get("password") != self.cleaned_data.get(
 | 
			
		||||
                "repeat_password"
 | 
			
		||||
            ):
 | 
			
		||||
                raise forms.ValidationError("Passwords do not match")
 | 
			
		||||
            return self.cleaned_data.get("repeat_password")
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, token):
 | 
			
		||||
        self.reset = get_object_or_404(PasswordReset, token=token)
 | 
			
		||||
        return super().dispatch(request)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        self.reset.user.set_password(form.cleaned_data["password"])
 | 
			
		||||
        self.reset.user.save()
 | 
			
		||||
        self.reset.delete()
 | 
			
		||||
        return render(
 | 
			
		||||
            self.request,
 | 
			
		||||
            "auth/reset_success.html",
 | 
			
		||||
            {"email": self.reset.user.email},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, *args, **kwargs):
 | 
			
		||||
        context = super().get_context_data(*args, **kwargs)
 | 
			
		||||
        context["reset"] = self.reset
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user