mirror of
https://github.com/jointakahe/takahe.git
synced 2024-12-23 05:56:29 +00:00
Add signup and password reset
This commit is contained in:
parent
2a3690d1c1
commit
6adfdbabe0
22 changed files with 435 additions and 18 deletions
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
|
||||
|
|
Loading…
Reference in a new issue