forked from Github-Mirrors/canaille
feat: implement email verification
This commit is contained in:
parent
29b1e3c411
commit
fd24c704c0
23 changed files with 1242 additions and 241 deletions
|
@ -120,6 +120,8 @@ def setup_flask(app):
|
|||
"user": current_user(),
|
||||
"menu": True,
|
||||
"is_boosted": request.headers.get("HX-Boosted", False),
|
||||
"has_email_confirmation": app.config.get("EMAIL_CONFIRMATION") is True
|
||||
or (app.config.get("EMAIL_CONFIRMATION") is None and "SMTP" in app.config),
|
||||
}
|
||||
|
||||
@app.errorhandler(400)
|
||||
|
|
|
@ -15,7 +15,7 @@ def b64_to_obj(string):
|
|||
return json.loads(base64.b64decode(string.encode("utf-8")).decode("utf-8"))
|
||||
|
||||
|
||||
def profile_hash(*args):
|
||||
def build_hash(*args):
|
||||
return hashlib.sha256(
|
||||
current_app.config["SECRET_KEY"].encode("utf-8")
|
||||
+ obj_to_b64(args).encode("utf-8")
|
||||
|
|
|
@ -38,6 +38,13 @@ SECRET_KEY = "change me before you go in production"
|
|||
# Accelerates webpages with async requests
|
||||
# HTMX = true
|
||||
|
||||
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
|
||||
# confirmation link sent by email when they want to add a new email.
|
||||
# By default, this is true if SMTP is configured, else this is false.
|
||||
# If explicitely set to true and SMTP is disabled, the email field
|
||||
# will be read-only.
|
||||
# EMAIL_CONFIRMATION =
|
||||
|
||||
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
|
||||
# tries to sign in with an invalid login, a message is shown indicating
|
||||
# that the password is wrong, but does not give a clue wether the login
|
||||
|
|
|
@ -7,10 +7,10 @@ from typing import List
|
|||
import pkg_resources
|
||||
import wtforms
|
||||
from canaille.app import b64_to_obj
|
||||
from canaille.app import build_hash
|
||||
from canaille.app import default_fields
|
||||
from canaille.app import models
|
||||
from canaille.app import obj_to_b64
|
||||
from canaille.app import profile_hash
|
||||
from canaille.app.flask import current_user
|
||||
from canaille.app.flask import permissions_needed
|
||||
from canaille.app.flask import render_htmx_template
|
||||
|
@ -37,6 +37,8 @@ from flask_themer import render_template
|
|||
from werkzeug.datastructures import CombinedMultiDict
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from .forms import build_profile_form
|
||||
from .forms import EmailConfirmationForm
|
||||
from .forms import FirstLoginForm
|
||||
from .forms import ForgottenPasswordForm
|
||||
from .forms import InvitationForm
|
||||
|
@ -44,8 +46,8 @@ from .forms import LoginForm
|
|||
from .forms import MINIMUM_PASSWORD_LENGTH
|
||||
from .forms import PasswordForm
|
||||
from .forms import PasswordResetForm
|
||||
from .forms import profile_form
|
||||
from .forms import PROFILE_FORM_FIELDS
|
||||
from .mails import send_confirmation_email
|
||||
from .mails import send_invitation_mail
|
||||
from .mails import send_password_initialization_mail
|
||||
from .mails import send_password_reset_mail
|
||||
|
@ -206,12 +208,8 @@ def users(user):
|
|||
|
||||
|
||||
@dataclass
|
||||
class Invitation:
|
||||
class Verification:
|
||||
creation_date_isoformat: str
|
||||
user_name: str
|
||||
user_name_editable: bool
|
||||
email: str
|
||||
groups: List[str]
|
||||
|
||||
@property
|
||||
def creation_date(self):
|
||||
|
@ -230,8 +228,22 @@ class Invitation:
|
|||
def b64(self):
|
||||
return obj_to_b64(astuple(self))
|
||||
|
||||
def profile_hash(self):
|
||||
return profile_hash(*astuple(self))
|
||||
def build_hash(self):
|
||||
return build_hash(*astuple(self))
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailConfirmationObject(Verification):
|
||||
identifier: str
|
||||
email: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invitation(Verification):
|
||||
user_name: str
|
||||
user_name_editable: bool
|
||||
email: str
|
||||
groups: List[str]
|
||||
|
||||
|
||||
@bp.route("/invite", methods=["GET", "POST"])
|
||||
|
@ -255,7 +267,7 @@ def user_invitation(user):
|
|||
registration_url = url_for(
|
||||
"account.registration",
|
||||
data=invitation.b64(),
|
||||
hash=invitation.profile_hash(),
|
||||
hash=invitation.build_hash(),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
|
@ -304,7 +316,7 @@ def registration(data, hash):
|
|||
)
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
if hash != invitation.profile_hash():
|
||||
if hash != invitation.build_hash():
|
||||
flash(
|
||||
_("The invitation link that brought you here was invalid."),
|
||||
"error",
|
||||
|
@ -316,10 +328,13 @@ def registration(data, hash):
|
|||
"emails": [invitation.email],
|
||||
"groups": invitation.groups,
|
||||
}
|
||||
|
||||
has_smtp = "SMTP" in current_app.config
|
||||
emails_readonly = current_app.config.get("EMAIL_CONFIRMATION") is True or (
|
||||
current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp
|
||||
)
|
||||
readable_fields, writable_fields = default_fields()
|
||||
|
||||
form = profile_form(writable_fields, readable_fields)
|
||||
form = build_profile_form(writable_fields, readable_fields)
|
||||
if "groups" not in form and invitation.groups:
|
||||
form["groups"] = wtforms.SelectMultipleField(
|
||||
_("Groups"),
|
||||
|
@ -331,6 +346,9 @@ def registration(data, hash):
|
|||
if is_readonly(form["user_name"]) and invitation.user_name_editable:
|
||||
set_writable(form["user_name"])
|
||||
|
||||
if not is_readonly(form["emails"]) and emails_readonly:
|
||||
set_readonly(form["emails"])
|
||||
|
||||
form["password1"].validators = [
|
||||
wtforms.validators.DataRequired(),
|
||||
wtforms.validators.Length(min=MINIMUM_PASSWORD_LENGTH),
|
||||
|
@ -367,10 +385,63 @@ def registration(data, hash):
|
|||
return redirect(url_for("account.profile_edition", edited_user=user))
|
||||
|
||||
|
||||
@bp.route("/email-confirmation/<data>/<hash>")
|
||||
def email_confirmation(data, hash):
|
||||
try:
|
||||
confirmation_obj = EmailConfirmationObject(*b64_to_obj(data))
|
||||
except:
|
||||
flash(
|
||||
_("The email confirmation link that brought you here is invalid."),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
if confirmation_obj.has_expired():
|
||||
flash(
|
||||
_("The email confirmation link that brought you here has expired."),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
if hash != confirmation_obj.build_hash():
|
||||
flash(
|
||||
_("The invitation link that brought you here was invalid."),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
user = models.User.get(confirmation_obj.identifier)
|
||||
if not user:
|
||||
flash(
|
||||
_("The email confirmation link that brought you here is invalid."),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
if confirmation_obj.email in user.emails:
|
||||
flash(
|
||||
_("This address email have already been confirmed."),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
if models.User.query(emails=confirmation_obj.email):
|
||||
flash(
|
||||
_("This address email is already associated with another account."),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
user.emails = user.emails + [confirmation_obj.email]
|
||||
user.save()
|
||||
flash(_("Your email address have been confirmed."), "success")
|
||||
return redirect(url_for("account.index"))
|
||||
|
||||
|
||||
@bp.route("/profile", methods=("GET", "POST"))
|
||||
@permissions_needed("manage_users")
|
||||
def profile_creation(user):
|
||||
form = profile_form(user.write, user.read)
|
||||
form = build_profile_form(user.write, user.read)
|
||||
form.process(CombinedMultiDict((request.files, request.form)) or None)
|
||||
|
||||
for field in form:
|
||||
|
@ -428,15 +499,7 @@ def profile_create(current_app, form):
|
|||
return user
|
||||
|
||||
|
||||
@bp.route("/profile/<user:edited_user>", methods=("GET", "POST"))
|
||||
@user_needed()
|
||||
def profile_edition(user, edited_user):
|
||||
if not user.can_manage_users and not (user.can_edit_self and edited_user == user):
|
||||
abort(404)
|
||||
|
||||
menuitem = "profile" if edited_user == user else "users"
|
||||
fields = user.read | user.write
|
||||
|
||||
def profile_edition_main_form(user, edited_user, emails_readonly):
|
||||
available_fields = {
|
||||
"formatted_name",
|
||||
"title",
|
||||
|
@ -458,44 +521,34 @@ def profile_edition(user, edited_user):
|
|||
"preferred_language",
|
||||
"organization",
|
||||
}
|
||||
if emails_readonly:
|
||||
available_fields.remove("emails")
|
||||
|
||||
readable_fields = user.read & available_fields
|
||||
writable_fields = user.write & available_fields
|
||||
data = {
|
||||
field: getattr(edited_user, field)[0]
|
||||
if getattr(edited_user, field)
|
||||
and isinstance(getattr(edited_user, field), list)
|
||||
and not PROFILE_FORM_FIELDS[field].field_class == wtforms.FieldList
|
||||
else getattr(edited_user, field) or ""
|
||||
for field in fields
|
||||
if hasattr(edited_user, field) and field in available_fields
|
||||
for field in writable_fields | readable_fields
|
||||
if hasattr(edited_user, field)
|
||||
}
|
||||
|
||||
form = profile_form(
|
||||
user.write & available_fields, user.read & available_fields, edited_user
|
||||
)
|
||||
form.process(CombinedMultiDict((request.files, request.form)) or None, data=data)
|
||||
form.render_field_macro_file = "partial/profile_field.html"
|
||||
form.render_field_extra_context = {
|
||||
request_data = CombinedMultiDict((request.files, request.form))
|
||||
profile_form = build_profile_form(writable_fields, readable_fields)
|
||||
profile_form.process(request_data or None, data=data)
|
||||
profile_form.user = edited_user
|
||||
profile_form.render_field_macro_file = "partial/profile_field.html"
|
||||
profile_form.render_field_extra_context = {
|
||||
"user": user,
|
||||
"edited_user": edited_user,
|
||||
}
|
||||
return profile_form
|
||||
|
||||
if not request.form or form.form_control():
|
||||
return render_template(
|
||||
"profile_edit.html",
|
||||
form=form,
|
||||
menuitem=menuitem,
|
||||
edited_user=edited_user,
|
||||
)
|
||||
|
||||
if not form.validate():
|
||||
flash(_("Profile edition failed."), "error")
|
||||
return render_template(
|
||||
"profile_edit.html",
|
||||
form=form,
|
||||
menuitem=menuitem,
|
||||
edited_user=edited_user,
|
||||
)
|
||||
|
||||
for attribute in form:
|
||||
def profile_edition_main_form_validation(user, edited_user, profile_form):
|
||||
for attribute in profile_form:
|
||||
if attribute.name in edited_user.attributes and attribute.name in user.write:
|
||||
if isinstance(attribute.data, FileStorage):
|
||||
data = attribute.data.stream.read()
|
||||
|
@ -504,20 +557,126 @@ def profile_edition(user, edited_user):
|
|||
|
||||
setattr(edited_user, attribute.name, data)
|
||||
|
||||
if "photo" in form and form["photo_delete"].data:
|
||||
if "photo" in profile_form and profile_form["photo_delete"].data:
|
||||
del edited_user.photo
|
||||
|
||||
if "preferred_language" in request.form:
|
||||
# Refresh the babel cache in case the lang is updated
|
||||
refresh()
|
||||
|
||||
if form["preferred_language"].data == "auto":
|
||||
if profile_form["preferred_language"].data == "auto":
|
||||
edited_user.preferred_language = None
|
||||
|
||||
edited_user.save()
|
||||
|
||||
|
||||
def profile_edition_emails_form(user, edited_user, has_smtp):
|
||||
emails_form = EmailConfirmationForm(
|
||||
request.form or None, data={"old_emails": edited_user.emails}
|
||||
)
|
||||
emails_form.add_email_button = has_smtp
|
||||
return emails_form
|
||||
|
||||
|
||||
def profile_edition_add_email(user, edited_user, emails_form):
|
||||
email_confirmation = EmailConfirmationObject(
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
edited_user.identifier,
|
||||
emails_form.new_email.data,
|
||||
)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=email_confirmation.b64(),
|
||||
hash=email_confirmation.build_hash(),
|
||||
_external=True,
|
||||
)
|
||||
current_app.logger.debug(
|
||||
f"Attempt to send a verification mail with link: {email_confirmation_url}"
|
||||
)
|
||||
return send_confirmation_email(emails_form.new_email.data, email_confirmation_url)
|
||||
|
||||
|
||||
def profile_edition_remove_email(user, edited_user, email):
|
||||
if email not in edited_user.emails:
|
||||
return False
|
||||
|
||||
if len(edited_user.emails) == 1:
|
||||
return False
|
||||
|
||||
edited_user.emails = [m for m in edited_user.emails if m != email]
|
||||
edited_user.save()
|
||||
return True
|
||||
|
||||
|
||||
@bp.route("/profile/<user:edited_user>", methods=("GET", "POST"))
|
||||
@user_needed()
|
||||
def profile_edition(user, edited_user):
|
||||
if not user.can_manage_users and not (user.can_edit_self and edited_user == user):
|
||||
abort(404)
|
||||
|
||||
menuitem = "profile" if edited_user == user else "users"
|
||||
has_smtp = "SMTP" in current_app.config
|
||||
has_email_confirmation = current_app.config.get("EMAIL_CONFIRMATION") is True or (
|
||||
current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp
|
||||
)
|
||||
emails_readonly = has_email_confirmation and not user.can_manage_users
|
||||
|
||||
profile_form = profile_edition_main_form(user, edited_user, emails_readonly)
|
||||
emails_form = (
|
||||
profile_edition_emails_form(user, edited_user, has_smtp)
|
||||
if emails_readonly
|
||||
else None
|
||||
)
|
||||
|
||||
render_context = {
|
||||
"menuitem": menuitem,
|
||||
"edited_user": edited_user,
|
||||
"profile_form": profile_form,
|
||||
"emails_form": emails_form,
|
||||
}
|
||||
|
||||
if not request.form or profile_form.form_control():
|
||||
return render_template("profile_edit.html", **render_context)
|
||||
|
||||
if request.form.get("action") == "edit-profile":
|
||||
if not profile_form.validate():
|
||||
flash(_("Profile edition failed."), "error")
|
||||
return render_template("profile_edit.html", **render_context)
|
||||
|
||||
profile_edition_main_form_validation(user, edited_user, profile_form)
|
||||
flash(_("Profile updated successfully."), "success")
|
||||
return redirect(url_for("account.profile_edition", edited_user=edited_user))
|
||||
|
||||
if request.form.get("action") == "add_email":
|
||||
if not emails_form.validate():
|
||||
flash(_("Email addition failed."), "error")
|
||||
return render_template("profile_edit.html", **render_context)
|
||||
|
||||
if profile_edition_add_email(user, edited_user, emails_form):
|
||||
flash(
|
||||
_(
|
||||
"An email has been sent to the email address. "
|
||||
"Please check your inbox and click on the verification link it contains"
|
||||
),
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash(_("Could not send the verification email"), "error")
|
||||
|
||||
return redirect(url_for("account.profile_edition", edited_user=edited_user))
|
||||
|
||||
if request.form.get("email_remove"):
|
||||
if not profile_edition_remove_email(
|
||||
user, edited_user, request.form.get("email_remove")
|
||||
):
|
||||
flash(_("Email deletion failed."), "error")
|
||||
return render_template("profile_edit.html", **render_context)
|
||||
|
||||
flash(_("The email have been successfully deleted."), "success")
|
||||
return redirect(url_for("account.profile_edition", edited_user=edited_user))
|
||||
|
||||
abort(400, f"bad form action: {request.form.get('action')}")
|
||||
|
||||
|
||||
@bp.route("/profile/<user:edited_user>/settings", methods=("GET", "POST"))
|
||||
@user_needed()
|
||||
|
@ -625,7 +784,7 @@ def profile_settings_edit(editor, edited_user):
|
|||
if "groups" in fields:
|
||||
data["groups"] = [g.id for g in edited_user.groups]
|
||||
|
||||
form = profile_form(
|
||||
form = build_profile_form(
|
||||
editor.write & available_fields, editor.read & available_fields, edited_user
|
||||
)
|
||||
form.process(CombinedMultiDict((request.files, request.form)) or None, data=data)
|
||||
|
@ -751,7 +910,7 @@ def reset(user, hash):
|
|||
|
||||
form = PasswordResetForm(request.form)
|
||||
hashes = {
|
||||
profile_hash(
|
||||
build_hash(
|
||||
user.identifier,
|
||||
email,
|
||||
user.password[0] if user.has_password() else "",
|
||||
|
@ -784,7 +943,7 @@ def photo(user, field):
|
|||
if request.if_modified_since and request.if_modified_since >= user.last_modified:
|
||||
return "", 304
|
||||
|
||||
etag = profile_hash(user.identifier, user.last_modified.isoformat())
|
||||
etag = build_hash(user.identifier, user.last_modified.isoformat())
|
||||
if request.if_none_match and etag in request.if_none_match:
|
||||
return "", 304
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from canaille.app import obj_to_b64
|
||||
from canaille.app.flask import permissions_needed
|
||||
from canaille.app.forms import Form
|
||||
from canaille.core.mails import profile_hash
|
||||
from canaille.core.mails import build_hash
|
||||
from canaille.core.mails import send_test_mail
|
||||
from flask import Blueprint
|
||||
from flask import current_app
|
||||
|
@ -79,7 +79,7 @@ def password_init_html(user):
|
|||
reset_url = url_for(
|
||||
"account.reset",
|
||||
user=user,
|
||||
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
title=_("Password initialization on {website_name}").format(
|
||||
website_name=current_app.config.get("NAME", "Canaille")
|
||||
),
|
||||
|
@ -105,7 +105,7 @@ def password_init_txt(user):
|
|||
reset_url = url_for(
|
||||
"account.reset",
|
||||
user=user,
|
||||
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
|
@ -124,7 +124,7 @@ def password_reset_html(user):
|
|||
reset_url = url_for(
|
||||
"account.reset",
|
||||
user=user,
|
||||
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
title=_("Password reset on {website_name}").format(
|
||||
website_name=current_app.config.get("NAME", "Canaille")
|
||||
),
|
||||
|
@ -150,7 +150,7 @@ def password_reset_txt(user):
|
|||
reset_url = url_for(
|
||||
"account.reset",
|
||||
user=user,
|
||||
hash=profile_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
hash=build_hash(user.identifier, user.preferred_email, user.password[0]),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
|
@ -169,7 +169,7 @@ def invitation_html(user, identifier, email):
|
|||
registration_url = url_for(
|
||||
"account.registration",
|
||||
data=obj_to_b64([identifier, email]),
|
||||
hash=profile_hash(identifier, email),
|
||||
hash=build_hash(identifier, email),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
|
@ -192,7 +192,7 @@ def invitation_txt(user, identifier, email):
|
|||
registration_url = url_for(
|
||||
"account.registration",
|
||||
data=obj_to_b64([identifier, email]),
|
||||
hash=profile_hash(identifier, email),
|
||||
hash=build_hash(identifier, email),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
|
@ -202,3 +202,45 @@ def invitation_txt(user, identifier, email):
|
|||
site_url=base_url,
|
||||
registration_url=registration_url,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/mail/<identifier>/<email>/email-confirmation.html")
|
||||
@permissions_needed("manage_oidc")
|
||||
def email_confirmation_html(user, identifier, email):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=obj_to_b64([identifier, email]),
|
||||
hash=build_hash(identifier, email),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"mail/email-confirmation.html",
|
||||
site_name=current_app.config.get("NAME", "Canaille"),
|
||||
site_url=base_url,
|
||||
confirmation_url=email_confirmation_url,
|
||||
logo=current_app.config.get("LOGO"),
|
||||
title=_("Email confirmation on {website_name}").format(
|
||||
website_name=current_app.config.get("NAME", "Canaille")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/mail/<identifier>/<email>/email-confirmation.txt")
|
||||
@permissions_needed("manage_oidc")
|
||||
def email_confirmation_txt(user, identifier, email):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=obj_to_b64([identifier, email]),
|
||||
hash=build_hash(identifier, email),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"mail/email-confirmation.txt",
|
||||
site_name=current_app.config.get("NAME", "Canaille"),
|
||||
site_url=base_url,
|
||||
confirmation_url=email_confirmation_url,
|
||||
)
|
||||
|
|
|
@ -284,7 +284,7 @@ PROFILE_FORM_FIELDS = dict(
|
|||
)
|
||||
|
||||
|
||||
def profile_form(write_field_names, readonly_field_names, user=None):
|
||||
def build_profile_form(write_field_names, readonly_field_names, user=None):
|
||||
if "password" in write_field_names:
|
||||
write_field_names |= {"password1", "password2"}
|
||||
|
||||
|
@ -379,3 +379,34 @@ class InvitationForm(Form):
|
|||
],
|
||||
render_kw={},
|
||||
)
|
||||
|
||||
|
||||
class EmailConfirmationForm(Form):
|
||||
old_emails = wtforms.FieldList(
|
||||
wtforms.EmailField(
|
||||
_("Email addresses"),
|
||||
validators=[ReadOnly()],
|
||||
description=_(
|
||||
"This email will be used as a recovery address to reset the password if needed"
|
||||
),
|
||||
render_kw={
|
||||
"placeholder": _("jane@doe.com"),
|
||||
"spellcheck": "false",
|
||||
"autocorrect": "off",
|
||||
"readonly": "true",
|
||||
},
|
||||
),
|
||||
)
|
||||
new_email = wtforms.EmailField(
|
||||
_("New email address"),
|
||||
validators=[
|
||||
wtforms.validators.DataRequired(),
|
||||
wtforms.validators.Email(),
|
||||
unique_email,
|
||||
],
|
||||
render_kw={
|
||||
"placeholder": _("jane@doe.com"),
|
||||
"spellcheck": "false",
|
||||
"autocorrect": "off",
|
||||
},
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from canaille.app import profile_hash
|
||||
from canaille.app import build_hash
|
||||
from canaille.app.mails import logo
|
||||
from canaille.app.mails import send_email
|
||||
from flask import current_app
|
||||
|
@ -41,7 +41,7 @@ def send_password_reset_mail(user, mail):
|
|||
reset_url = url_for(
|
||||
"account.reset",
|
||||
user=user,
|
||||
hash=profile_hash(
|
||||
hash=build_hash(
|
||||
user.identifier,
|
||||
mail,
|
||||
user.password[0] if user.has_password() else "",
|
||||
|
@ -82,7 +82,7 @@ def send_password_initialization_mail(user, email):
|
|||
reset_url = url_for(
|
||||
"account.reset",
|
||||
user=user,
|
||||
hash=profile_hash(
|
||||
hash=build_hash(
|
||||
user.identifier,
|
||||
email,
|
||||
user.password[0] if user.has_password() else "",
|
||||
|
@ -147,3 +147,34 @@ def send_invitation_mail(email, registration_url):
|
|||
html=html_body,
|
||||
attachements=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
|
||||
)
|
||||
|
||||
|
||||
def send_confirmation_email(email, confirmation_url):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
logo_cid, logo_filename, logo_raw = logo()
|
||||
|
||||
subject = _("Confirm your address email on {website_name}").format(
|
||||
website_name=current_app.config.get("NAME", base_url)
|
||||
)
|
||||
text_body = render_template(
|
||||
"mail/email-confirmation.txt",
|
||||
site_name=current_app.config.get("NAME", base_url),
|
||||
site_url=base_url,
|
||||
confirmation_url=confirmation_url,
|
||||
)
|
||||
html_body = render_template(
|
||||
"mail/email-confirmation.html",
|
||||
site_name=current_app.config.get("NAME", base_url),
|
||||
site_url=base_url,
|
||||
confirmation_url=confirmation_url,
|
||||
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
|
||||
title=subject,
|
||||
)
|
||||
|
||||
return send_email(
|
||||
subject=subject,
|
||||
recipient=email,
|
||||
text=text_body,
|
||||
html=html_body,
|
||||
attachements=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
|
||||
)
|
||||
|
|
|
@ -39,7 +39,7 @@ del_button=false
|
|||
<div class="ui
|
||||
{%- if corner_indicator %} corner labeled{% endif -%}
|
||||
{%- if icon or field.description %} left icon{% endif -%}
|
||||
{%- if field_visible and (add_button or del_button) %} action{% endif -%}
|
||||
{%- if field_visible and ((add_button or del_button) and not readonly and not disabled) or caller is defined %} action{% endif -%}
|
||||
{%- if field.type not in ("BooleanField", "RadioField") %} input{% endif -%}
|
||||
">
|
||||
{% endif %}
|
||||
|
@ -75,6 +75,9 @@ del_button=false
|
|||
{% endif %}
|
||||
|
||||
{% if field_visible %}
|
||||
{% if caller is defined %}
|
||||
{{ caller() }}
|
||||
{% else %}
|
||||
{% if del_button %}
|
||||
<button
|
||||
class="ui teal icon button"
|
||||
|
@ -82,7 +85,6 @@ del_button=false
|
|||
type="submit"
|
||||
name="fieldlist_remove"
|
||||
value="{{ field.name }}"
|
||||
hx-post=""
|
||||
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
|
||||
hx-vals='{"fieldlist_remove": "{{ field.name }}"}'
|
||||
hx-target="closest .fieldlist"
|
||||
|
@ -90,14 +92,13 @@ del_button=false
|
|||
<i class="minus icon"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if add_button %}
|
||||
{% if add_button and not readonly and not disabled %}
|
||||
<button
|
||||
class="ui teal icon button"
|
||||
title="{{ _("Add another field") }}"
|
||||
type="submit"
|
||||
name="fieldlist_add"
|
||||
value="{{ field.name }}"
|
||||
hx-post=""
|
||||
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
|
||||
hx-vals='{"fieldlist_add": "{{ field.name }}"}'
|
||||
hx-target="closest .fieldlist"
|
||||
|
@ -105,6 +106,7 @@ del_button=false
|
|||
<i class="plus icon"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -98,6 +98,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="right floated content">
|
||||
<div class="ui buttons">
|
||||
<a class="ui button primary" href="{{ url_for("admin.email_confirmation_txt", identifier=user.identifier, email=user.preferred_email) }}">TXT</a>
|
||||
<a class="ui button primary" href="{{ url_for("admin.email_confirmation_html", identifier=user.identifier, email=user.preferred_email) }}">HTML</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="middle aligned content">
|
||||
{{ _("Email verification") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="item">
|
||||
<div class="right floated content">
|
||||
<div class="ui buttons">
|
||||
|
|
44
canaille/templates/mail/email-confirmation.html
Normal file
44
canaille/templates/mail/email-confirmation.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="https://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width"/>
|
||||
<style type="text/css" style="font-weight: 300">@import url({{ url_for('static', filename='fonts/lato.css', _external=True) }});</style>
|
||||
<title>{{ title }}</title>
|
||||
</head>
|
||||
<body style="color: rgba(0,0,0,.87); padding: 1em; margin: auto; width: 700px; font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; font-weight: 400; font-size: 14px;">
|
||||
|
||||
<table cellspacing="0" cellpadding="0" border="0" style="font-weight: 400; background: #fff; font-size: 1rem; margin-top: 0; margin-bottom: 0; width: 700px;">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<h3 style="font-weight: 700; line-height: 1.3em; font-size: 1.3rem; padding: .8rem 1rem; margin: 0; box-shadow: none; border: 1px solid #d4d4d5; border-radius: .3rem .3rem 0 0;">
|
||||
{% if logo %}
|
||||
<img src="{{ logo }}" alt="{{ site_name }}" style="font-size: 1.3rem; border-style: none; width: 50px; display: inline-block; margin-top: .14em; vertical-align: middle;">
|
||||
{% endif %}
|
||||
<div style="font-size: 1.3rem; display: inline-block; padding-left: .75rem; vertical-align: middle;">
|
||||
{% trans %}Email address confirmation{% endtrans %}
|
||||
</div>
|
||||
</h3>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
|
||||
{% trans %}
|
||||
You added an email address to your account on {{ site_name }}.
|
||||
Please click on the "Validate email address" button below to confirm your email address.
|
||||
{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
|
||||
<td style="background: #e0e1e2; width:50%; border-radius: 0 0 0 .3rem;">
|
||||
<a href="{{ site_url }}" style="width: 100%; display: inline-block; vertical-align: middle; color: rgba(0,0,0,.6); padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem;margin: 0; border-left: none; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{{ site_name }}</a>
|
||||
</td>
|
||||
<td style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
|
||||
<a href="{{ confirmation_url }}" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %}Validate email address{% endtrans %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
9
canaille/templates/mail/email-confirmation.txt
Normal file
9
canaille/templates/mail/email-confirmation.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
# {% trans %}Email verification{% endtrans %}
|
||||
|
||||
{% trans %}
|
||||
You added an email address to your account on {{ site_name }}.
|
||||
Please click on the link below to confirm your email address.
|
||||
{% endtrans %}
|
||||
|
||||
{% trans %}Verification link{% endtrans %}: {{ confirmation_url }}
|
||||
{{ site_name }}: {{ site_url }}
|
|
@ -49,17 +49,17 @@
|
|||
</div>
|
||||
</h2>
|
||||
|
||||
{% call fui.render_form(form) %}
|
||||
{% if "photo" in form %}
|
||||
{% call fui.render_form(profile_form) %}
|
||||
{% if "photo" in profile_form %}
|
||||
<div class="ui grid">
|
||||
<div class="three wide column">
|
||||
{% block photo_field scoped %}
|
||||
{{ profile.render_field(form.photo, display=false, class="photo-field") }}
|
||||
{{ profile.render_field(form.photo_delete, display=false, class="photo-delete-button") }}
|
||||
{{ profile.render_field(profile_form.photo, display=false, class="photo-field") }}
|
||||
{{ profile.render_field(profile_form.photo_delete, display=false, class="photo-delete-button") }}
|
||||
{% set photo = edited_user.photo and edited_user.photo[0] %}
|
||||
<label
|
||||
class="ui small bordered image photo-content"
|
||||
for="{{ form.photo.id }}"
|
||||
for="{{ profile_form.photo.id }}"
|
||||
title="{{ _("Click to upload a photo") }}"
|
||||
{% if not photo %}style="display: none;"{% endif %}>
|
||||
|
||||
|
@ -70,7 +70,7 @@
|
|||
</label>
|
||||
<label
|
||||
class="ui centered photo-placeholder"
|
||||
for="{{ form.photo.id }}"
|
||||
for="{{ profile_form.photo.id }}"
|
||||
title="{{ _("Click to upload a photo") }}"
|
||||
{% if photo %}style="display: none;"{% endif %}>
|
||||
<i class="massive centered portrait icon"></i>
|
||||
|
@ -81,83 +81,83 @@
|
|||
<div class="thirteen wide column">
|
||||
{% endif %}
|
||||
|
||||
{% if "given_name" in form or "family_name" in form %}
|
||||
{% if "given_name" in profile_form or "family_name" in profile_form %}
|
||||
<div class="equal width fields">
|
||||
{% if "given_name" in form %}
|
||||
{% block given_name_field scoped %}{{ profile.render_field(form.given_name, user, edited_user) }}{% endblock %}
|
||||
{% if "given_name" in profile_form %}
|
||||
{% block given_name_field scoped %}{{ profile.render_field(profile_form.given_name, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "family_name" in form %}
|
||||
{% block sn_field scoped %}{{ profile.render_field(form.family_name, user, edited_user) }}{% endblock %}
|
||||
{% if "family_name" in profile_form %}
|
||||
{% block sn_field scoped %}{{ profile.render_field(profile_form.family_name, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if "display_name" in form %}
|
||||
{% block display_name_field scoped %}{{ profile.render_field(form.display_name, user, edited_user) }}{% endblock %}
|
||||
{% if "display_name" in profile_form %}
|
||||
{% block display_name_field scoped %}{{ profile.render_field(profile_form.display_name, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "photo" in form %}</div></div>{% endif %}
|
||||
{% if "photo" in profile_form %}</div></div>{% endif %}
|
||||
|
||||
{% if "emails" in form %}
|
||||
{% block emails_field scoped %}{{ profile.render_field(form.emails, user, edited_user) }}{% endblock %}
|
||||
{% if "emails" in profile_form and not emails_form %}
|
||||
{% block emails_field scoped %}{{ profile.render_field(profile_form.emails, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "phone_numbers" in form %}
|
||||
{% block phone_numbers_field scoped %}{{ profile.render_field(form.phone_numbers, user, edited_user) }}{% endblock %}
|
||||
{% if "phone_numbers" in profile_form %}
|
||||
{% block phone_numbers_field scoped %}{{ profile.render_field(profile_form.phone_numbers, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "formatted_address" in form %}
|
||||
{% block formatted_address_field scoped %}{{ profile.render_field(form.formatted_address, user, edited_user) }}{% endblock %}
|
||||
{% if "formatted_address" in profile_form %}
|
||||
{% block formatted_address_field scoped %}{{ profile.render_field(profile_form.formatted_address, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "street" in form %}
|
||||
{% block street_field scoped %}{{ profile.render_field(form.street, user, edited_user) }}{% endblock %}
|
||||
{% if "street" in profile_form %}
|
||||
{% block street_field scoped %}{{ profile.render_field(profile_form.street, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
<div class="equal width fields">
|
||||
{% if "postal_code" in form %}
|
||||
{% block postal_code_field scoped %}{{ profile.render_field(form.postal_code, user, edited_user) }}{% endblock %}
|
||||
{% if "postal_code" in profile_form %}
|
||||
{% block postal_code_field scoped %}{{ profile.render_field(profile_form.postal_code, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "locality" in form %}
|
||||
{% block locality_field scoped %}{{ profile.render_field(form.locality, user, edited_user) }}{% endblock %}
|
||||
{% if "locality" in profile_form %}
|
||||
{% block locality_field scoped %}{{ profile.render_field(profile_form.locality, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "region" in form %}
|
||||
{% block region_field scoped %}{{ profile.render_field(form.region, user, edited_user) }}{% endblock %}
|
||||
{% if "region" in profile_form %}
|
||||
{% block region_field scoped %}{{ profile.render_field(profile_form.region, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="equal width fields">
|
||||
|
||||
{% if "department" in form %}
|
||||
{% block department_number_field scoped %}{{ profile.render_field(form.department, user, edited_user) }}{% endblock %}
|
||||
{% if "department" in profile_form %}
|
||||
{% block department_number_field scoped %}{{ profile.render_field(profile_form.department, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "employee_number" in form %}
|
||||
{% block employee_number_field scoped %}{{ profile.render_field(form.employee_number, user, edited_user) }}{% endblock %}
|
||||
{% if "employee_number" in profile_form %}
|
||||
{% block employee_number_field scoped %}{{ profile.render_field(profile_form.employee_number, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="equal width fields">
|
||||
|
||||
{% if "title" in form %}
|
||||
{% block title_field scoped %}{{ profile.render_field(form.title, user, edited_user) }}{% endblock %}
|
||||
{% if "title" in profile_form %}
|
||||
{% block title_field scoped %}{{ profile.render_field(profile_form.title, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "organization" in form %}
|
||||
{% block organization_field scoped %}{{ profile.render_field(form.organization, user, edited_user) }}{% endblock %}
|
||||
{% if "organization" in profile_form %}
|
||||
{% block organization_field scoped %}{{ profile.render_field(profile_form.organization, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if "profile_url" in form %}
|
||||
{% block profile_url_field scoped %}{{ profile.render_field(form.profile_url, user, edited_user) }}{% endblock %}
|
||||
{% if "profile_url" in profile_form %}
|
||||
{% block profile_url_field scoped %}{{ profile.render_field(profile_form.profile_url, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if "preferred_language" in form %}
|
||||
{% block preferred_language_field scoped %}{{ profile.render_field(form.preferred_language, user, edited_user) }}{% endblock %}
|
||||
{% if "preferred_language" in profile_form %}
|
||||
{% block preferred_language_field scoped %}{{ profile.render_field(profile_form.preferred_language, user, edited_user) }}{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
<div class="ui right aligned container">
|
||||
|
@ -168,5 +168,69 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{% if emails_form %}
|
||||
<h2 class="ui center aligned header">
|
||||
<div class="content">
|
||||
{% if user.user_name == edited_user.user_name %}
|
||||
{% trans %}My email addresses{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}Email addresses{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
{% call fui.render_form(emails_form) %}
|
||||
<div class="field fieldlist">
|
||||
{{ emails_form.old_emails.label() }}
|
||||
{% for field in emails_form.old_emails %}
|
||||
<div class="field">
|
||||
<div class="ui corner labeled left icon{% if emails_form.old_emails|len>1 %} action{% endif %} input">
|
||||
{{ field(readonly=True) }}
|
||||
{% if field.description %}
|
||||
<i class="question circle link icon" title="{{ field.description }}"></i>
|
||||
{% endif %}
|
||||
|
||||
{% if emails_form.old_emails|len > 1 %}
|
||||
<button
|
||||
class="ui teal icon button"
|
||||
title="{{ _("Remove this email address") }}"
|
||||
type="submit"
|
||||
name="email_remove"
|
||||
value="{{ field.data }}"
|
||||
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
|
||||
hx-vals='{"email_remove": "{{ field.data }}"}'
|
||||
hx-target="closest .fieldlist"
|
||||
formnovalidate>
|
||||
<i class="minus icon"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if emails_form.add_email_button %}
|
||||
{% call fui.render_field(emails_form.new_email) %}
|
||||
<button
|
||||
class="ui primary right labeled icon button"
|
||||
type="submit"
|
||||
name="action"
|
||||
value="add_email"
|
||||
title="{% trans %}Send a verification email to validate this address.{% endtrans %}"
|
||||
{# Workaround for https://github.com/bigskysoftware/htmx/issues/1506 #}
|
||||
hx-vals='{"action": "add_email"}'
|
||||
hx-target="closest .fieldlist"
|
||||
formnovalidate
|
||||
>
|
||||
<i class="send icon"></i>
|
||||
{% trans %}
|
||||
Verify
|
||||
{% endtrans %}
|
||||
</button>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -38,6 +38,13 @@ FAVICON = "/static/img/canaille-c.png"
|
|||
# Accelerates webpages with async requests
|
||||
# HTMX = true
|
||||
|
||||
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
|
||||
# confirmation link sent by email when they want to add a new email.
|
||||
# By default, this is true if SMTP is configured, else this is false.
|
||||
# If explicitely set to true and SMTP is disabled, the email field
|
||||
# will be read-only.
|
||||
# EMAIL_CONFIRMATION =
|
||||
|
||||
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
|
||||
# tries to sign in with an invalid login, a message is shown indicating
|
||||
# that the password is wrong, but does not give a clue wether the login
|
||||
|
|
|
@ -38,6 +38,13 @@ FAVICON = "/static/img/canaille-c.png"
|
|||
# Accelerates webpages with async requests
|
||||
# HTMX = true
|
||||
|
||||
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
|
||||
# confirmation link sent by email when they want to add a new email.
|
||||
# By default, this is true if SMTP is configured, else this is false.
|
||||
# If explicitely set to true and SMTP is disabled, the email field
|
||||
# will be read-only.
|
||||
# EMAIL_CONFIRMATION =
|
||||
|
||||
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
|
||||
# tries to sign in with an invalid login, a message is shown indicating
|
||||
# that the password is wrong, but does not give a clue wether the login
|
||||
|
|
|
@ -57,6 +57,12 @@ Canaille is based on Flask, so any `flask configuration <https://flask.palletspr
|
|||
This needs the ``sentry_sdk`` python package to be installed.
|
||||
This is useful if you want to collect the canaille exceptions in a production environment.
|
||||
|
||||
:EMAIL_CONFIRMATION:
|
||||
*Optional.* If set to true, users will need to click on
|
||||
a confirmation link sent by email when they want to add a new email. By default,
|
||||
this is true if SMTP is configured, else this is false. If explicitely set to
|
||||
true and SMTP is disabled, the email field will be read-only.
|
||||
|
||||
:HIDE_INVALID_LOGINS:
|
||||
*Optional.* Wether to tell the users if a username exists during failing login attempts.
|
||||
Defaults to ``True``. This may be a security issue to disable this, as this give a way to malicious people to if an account exists on this canaille instance.
|
||||
|
|
|
@ -412,13 +412,14 @@ def test_fieldlist_add_readonly(testclient, logged_user, configuration):
|
|||
configuration["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" in res.form["phone_numbers-0"].attrs
|
||||
assert "phone_numbers-1" not in res.form.fields
|
||||
form = res.forms["baseform"]
|
||||
assert "readonly" in form["phone_numbers-0"].attrs
|
||||
assert "phone_numbers-1" not in form.fields
|
||||
|
||||
data = {
|
||||
"csrf_token": res.form["csrf_token"].value,
|
||||
"family_name": res.form["family_name"].value,
|
||||
"phone_numbers-0": res.form["phone_numbers-0"].value,
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
"family_name": form["family_name"].value,
|
||||
"phone_numbers-0": form["phone_numbers-0"].value,
|
||||
"fieldlist_add": "phone_numbers-0",
|
||||
}
|
||||
testclient.post("/profile/user", data, status=403)
|
||||
|
@ -431,13 +432,14 @@ def test_fieldlist_remove_readonly(testclient, logged_user, configuration):
|
|||
logged_user.save()
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" in res.form["phone_numbers-0"].attrs
|
||||
assert "readonly" in res.form["phone_numbers-1"].attrs
|
||||
form = res.forms["baseform"]
|
||||
assert "readonly" in form["phone_numbers-0"].attrs
|
||||
assert "readonly" in form["phone_numbers-1"].attrs
|
||||
|
||||
data = {
|
||||
"csrf_token": res.form["csrf_token"].value,
|
||||
"family_name": res.form["family_name"].value,
|
||||
"phone_numbers-0": res.form["phone_numbers-0"].value,
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
"family_name": form["family_name"].value,
|
||||
"phone_numbers-0": form["phone_numbers-0"].value,
|
||||
"fieldlist_remove": "phone_numbers-1",
|
||||
}
|
||||
testclient.post("/profile/user", data, status=403)
|
||||
|
|
|
@ -6,40 +6,44 @@ def test_preferred_language(testclient, logged_user):
|
|||
logged_user.save()
|
||||
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
assert res.form["preferred_language"].value == "auto"
|
||||
form = res.forms["baseform"]
|
||||
assert form["preferred_language"].value == "auto"
|
||||
assert res.pyquery("html")[0].attrib["lang"] == "en"
|
||||
res.mustcontain("My profile")
|
||||
res.mustcontain(no="Mon profil")
|
||||
|
||||
res.form["preferred_language"] = "fr"
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form["preferred_language"] = "fr"
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Le profil a été mis à jour avec succès.")]
|
||||
res = res.follow()
|
||||
form = res.forms["baseform"]
|
||||
logged_user.reload()
|
||||
assert logged_user.preferred_language == "fr"
|
||||
assert res.form["preferred_language"].value == "fr"
|
||||
assert form["preferred_language"].value == "fr"
|
||||
assert res.pyquery("html")[0].attrib["lang"] == "fr"
|
||||
res.mustcontain(no="My profile")
|
||||
res.mustcontain("Mon profil")
|
||||
|
||||
res.form["preferred_language"] = "en"
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form["preferred_language"] = "en"
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Profile updated successfully.")]
|
||||
res = res.follow()
|
||||
form = res.forms["baseform"]
|
||||
logged_user.reload()
|
||||
assert logged_user.preferred_language == "en"
|
||||
assert res.form["preferred_language"].value == "en"
|
||||
assert form["preferred_language"].value == "en"
|
||||
assert res.pyquery("html")[0].attrib["lang"] == "en"
|
||||
res.mustcontain("My profile")
|
||||
res.mustcontain(no="Mon profil")
|
||||
|
||||
res.form["preferred_language"] = "auto"
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form["preferred_language"] = "auto"
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Profile updated successfully.")]
|
||||
res = res.follow()
|
||||
form = res.forms["baseform"]
|
||||
logged_user.reload()
|
||||
assert logged_user.preferred_language is None
|
||||
assert res.form["preferred_language"].value == "auto"
|
||||
assert form["preferred_language"].value == "auto"
|
||||
assert res.pyquery("html")[0].attrib["lang"] == "en"
|
||||
res.mustcontain("My profile")
|
||||
res.mustcontain(no="Mon profil")
|
||||
|
@ -50,11 +54,12 @@ def test_form_translations(testclient, logged_user):
|
|||
logged_user.save()
|
||||
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
res.form["emails-0"] = "invalid"
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form = res.forms["baseform"]
|
||||
form["phone_numbers-0"] = "invalid"
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
|
||||
res.mustcontain(no="Invalid email address.")
|
||||
res.mustcontain("Adresse électronique non valide.")
|
||||
res.mustcontain(no="Not a valid phone number")
|
||||
res.mustcontain("N’est pas un numéro de téléphone valide")
|
||||
|
||||
|
||||
def test_language_config(testclient, logged_user):
|
||||
|
|
|
@ -162,7 +162,13 @@ def test_mail_with_logo_in_http(testclient, logged_admin, smtpd, httpserver):
|
|||
|
||||
|
||||
def test_mail_debug_pages(testclient, logged_admin):
|
||||
for base in ["test", "password-init", "reset", "admin/admin@admin.com/invitation"]:
|
||||
for base in [
|
||||
"test",
|
||||
"password-init",
|
||||
"reset",
|
||||
"admin/admin@admin.com/invitation",
|
||||
"admin/admin@admin.com/email-confirmation",
|
||||
]:
|
||||
testclient.get(f"/admin/mail/{base}.html")
|
||||
testclient.get(f"/admin/mail/{base}.txt")
|
||||
|
||||
|
|
524
tests/core/test_email_confirmation.py
Normal file
524
tests/core/test_email_confirmation.py
Normal file
|
@ -0,0 +1,524 @@
|
|||
import datetime
|
||||
from unittest import mock
|
||||
|
||||
import freezegun
|
||||
from canaille.core.account import EmailConfirmationObject
|
||||
from canaille.core.account import Invitation
|
||||
from flask import url_for
|
||||
|
||||
|
||||
def test_confirmation_disabled_email_editable(testclient, backend, logged_user):
|
||||
"""
|
||||
If email confirmation is disabled, users should be able to pick
|
||||
any email.
|
||||
"""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = False
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
assert not any(field.id == "add_email" for field in res.form.fields["action"])
|
||||
|
||||
res = res.form.submit(name="fieldlist_add", value="emails-0")
|
||||
res.form["emails-0"] = "email1@mydomain.tld"
|
||||
res.form["emails-1"] = "email2@mydomain.tld"
|
||||
|
||||
res = res.form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Profile updated successfully.")]
|
||||
res = res.follow()
|
||||
|
||||
logged_user.reload()
|
||||
|
||||
assert logged_user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
|
||||
|
||||
|
||||
def test_confirmation_unset_smtp_disabled_email_editable(
|
||||
testclient, backend, logged_admin, user
|
||||
):
|
||||
"""
|
||||
If email confirmation is unset and no SMTP server has
|
||||
been configured, then email confirmation cannot be enabled,
|
||||
thus users must be able to pick any email.
|
||||
"""
|
||||
del testclient.app.config["SMTP"]
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = None
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
assert not any(field.id == "add_email" for field in res.form.fields["action"])
|
||||
|
||||
res = res.form.submit(name="fieldlist_add", value="emails-0")
|
||||
res.form["emails-0"] = "email1@mydomain.tld"
|
||||
res.form["emails-1"] = "email2@mydomain.tld"
|
||||
|
||||
res = res.form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Profile updated successfully.")]
|
||||
res = res.follow()
|
||||
|
||||
user.reload()
|
||||
assert user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
|
||||
|
||||
|
||||
def test_confirmation_enabled_smtp_disabled_readonly(testclient, backend, logged_user):
|
||||
"""
|
||||
If email confirmation is enabled and no SMTP server is configured,
|
||||
this might be a misconfiguration, or a temporary SMTP disabling.
|
||||
In doubt, users cannot edit their emails.
|
||||
"""
|
||||
del testclient.app.config["SMTP"]
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = True
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
|
||||
assert "emails-0" not in res.forms["baseform"].fields
|
||||
|
||||
res.forms["emailconfirmationform"]["old_emails-0"] = "email1@mydomain.tld"
|
||||
assert "action" not in res.forms["emailconfirmationform"].fields
|
||||
|
||||
|
||||
def test_confirmation_unset_smtp_enabled_email_admin_editable(
|
||||
testclient, backend, logged_admin, user
|
||||
):
|
||||
"""
|
||||
Administrators should be able to edit user email addresses,
|
||||
even when email confirmation is unset and SMTP is configured.
|
||||
"""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = None
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
assert not any(field.id == "add_email" for field in res.form.fields["action"])
|
||||
|
||||
res = res.form.submit(name="fieldlist_add", value="emails-0")
|
||||
res.form["emails-0"] = "email1@mydomain.tld"
|
||||
res.form["emails-1"] = "email2@mydomain.tld"
|
||||
|
||||
res = res.form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Profile updated successfully.")]
|
||||
res = res.follow()
|
||||
|
||||
user.reload()
|
||||
assert user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
|
||||
|
||||
|
||||
def test_confirmation_enabled_smtp_disabled_admin_editable(
|
||||
testclient, backend, logged_admin, user
|
||||
):
|
||||
"""
|
||||
Administrators should be able to edit user email addresses,
|
||||
even when email confirmation is enabled and SMTP is disabled.
|
||||
"""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = True
|
||||
del testclient.app.config["SMTP"]
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
assert not any(field.id == "add_email" for field in res.form.fields["action"])
|
||||
|
||||
res = res.form.submit(name="fieldlist_add", value="emails-0")
|
||||
res.form["emails-0"] = "email1@mydomain.tld"
|
||||
res.form["emails-1"] = "email2@mydomain.tld"
|
||||
|
||||
res = res.form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Profile updated successfully.")]
|
||||
res = res.follow()
|
||||
|
||||
user.reload()
|
||||
assert user.emails == ["email1@mydomain.tld", "email2@mydomain.tld"]
|
||||
|
||||
|
||||
def test_confirmation_unset_smtp_enabled_email_user_validation(
|
||||
smtpd, testclient, backend, user
|
||||
):
|
||||
"""
|
||||
If email confirmation is unset and there is a SMTP server
|
||||
configured, then users emails should be validated by sending
|
||||
a confirmation email.
|
||||
"""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = None
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get("/login")
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit().follow()
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit()
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 02:00:00"):
|
||||
res = testclient.get("/profile/user")
|
||||
|
||||
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 02:00:00"):
|
||||
res.forms["emailconfirmationform"]["new_email"] = "new_email@mydomain.tld"
|
||||
res = res.forms["emailconfirmationform"].submit(
|
||||
name="action", value="add_email"
|
||||
)
|
||||
|
||||
assert res.flashes == [
|
||||
(
|
||||
"success",
|
||||
"An email has been sent to the email address. "
|
||||
"Please check your inbox and click on the verification link it contains",
|
||||
)
|
||||
]
|
||||
|
||||
email_confirmation = EmailConfirmationObject(
|
||||
"2020-01-01T02:00:00+00:00",
|
||||
"user",
|
||||
"new_email@mydomain.tld",
|
||||
)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=email_confirmation.b64(),
|
||||
hash=email_confirmation.build_hash(),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
assert len(smtpd.messages) == 1
|
||||
assert email_confirmation_url in str(smtpd.messages[0].get_payload()[0]).replace(
|
||||
"=\n", ""
|
||||
)
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 03:00:00"):
|
||||
res = testclient.get(email_confirmation_url)
|
||||
|
||||
assert ("success", "Your email address have been confirmed.") in res.flashes
|
||||
user.reload()
|
||||
assert "new_email@mydomain.tld" in user.emails
|
||||
|
||||
|
||||
def test_confirmation_invalid_link(testclient, backend, user):
|
||||
"""
|
||||
Random confirmation links should fail.
|
||||
"""
|
||||
res = testclient.get("/email-confirmation/invalid/invalid")
|
||||
assert (
|
||||
"error",
|
||||
"The email confirmation link that brought you here is invalid.",
|
||||
) in res.flashes
|
||||
|
||||
|
||||
def test_confirmation_mail_form_failed(testclient, backend, user):
|
||||
"""
|
||||
Tests when an error happens during the mail sending.
|
||||
"""
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get("/login")
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit().follow()
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit()
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 02:00:00"):
|
||||
res = testclient.get("/profile/user")
|
||||
|
||||
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 02:00:00"):
|
||||
res.forms["emailconfirmationform"]["new_email"] = "invalid"
|
||||
res = res.forms["emailconfirmationform"].submit(
|
||||
name="action", value="add_email"
|
||||
)
|
||||
|
||||
assert res.flashes == [("error", "Email addition failed.")]
|
||||
user.reload()
|
||||
assert user.emails == ["john@doe.com"]
|
||||
|
||||
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_confirmation_mail_send_failed(SMTP, smtpd, testclient, backend, user):
|
||||
"""
|
||||
Tests when an error happens during the mail sending.
|
||||
"""
|
||||
SMTP.side_effect = mock.Mock(side_effect=OSError("unit test mail error"))
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get("/login")
|
||||
res.form["login"] = "user"
|
||||
res = res.form.submit().follow()
|
||||
res.form["password"] = "correct horse battery staple"
|
||||
res = res.form.submit()
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 02:00:00"):
|
||||
res = testclient.get("/profile/user")
|
||||
|
||||
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 02:00:00"):
|
||||
res.forms["emailconfirmationform"]["new_email"] = "new_email@mydomain.tld"
|
||||
res = res.forms["emailconfirmationform"].submit(
|
||||
name="action", value="add_email", expect_errors=True
|
||||
)
|
||||
|
||||
assert res.flashes == [("error", "Could not send the verification email")]
|
||||
user.reload()
|
||||
assert user.emails == ["john@doe.com"]
|
||||
|
||||
|
||||
def test_confirmation_expired_link(testclient, backend, user):
|
||||
"""
|
||||
Expired valid confirmation links should fail.
|
||||
"""
|
||||
email_confirmation = EmailConfirmationObject(
|
||||
"2020-01-01T01:00:00+00:00",
|
||||
"user",
|
||||
"new_email@mydomain.tld",
|
||||
)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=email_confirmation.b64(),
|
||||
hash=email_confirmation.build_hash(),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
with freezegun.freeze_time("2021-01-01 01:00:00"):
|
||||
res = testclient.get(email_confirmation_url)
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The email confirmation link that brought you here has expired.",
|
||||
) in res.flashes
|
||||
user.reload()
|
||||
assert "new_email@mydomain.tld" not in user.emails
|
||||
|
||||
|
||||
def test_confirmation_invalid_hash_link(testclient, backend, user):
|
||||
"""
|
||||
Confirmation link with invalid hashes should fail.
|
||||
"""
|
||||
email_confirmation = EmailConfirmationObject(
|
||||
"2020-01-01T01:00:00+00:00",
|
||||
"user",
|
||||
"new_email@mydomain.tld",
|
||||
)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=email_confirmation.b64(),
|
||||
hash="invalid",
|
||||
_external=True,
|
||||
)
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get(email_confirmation_url)
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The invitation link that brought you here was invalid.",
|
||||
) in res.flashes
|
||||
user.reload()
|
||||
assert "new_email@mydomain.tld" not in user.emails
|
||||
|
||||
|
||||
def test_confirmation_invalid_user_link(testclient, backend, user):
|
||||
"""
|
||||
Confirmation link about an unexisting user should fail.
|
||||
For instance, when the user account has been deleted between
|
||||
the mail is sent and the link is clicked.
|
||||
"""
|
||||
email_confirmation = EmailConfirmationObject(
|
||||
"2020-01-01T01:00:00+00:00",
|
||||
"invalid-user",
|
||||
"new_email@mydomain.tld",
|
||||
)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=email_confirmation.b64(),
|
||||
hash=email_confirmation.build_hash(),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get(email_confirmation_url)
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"The email confirmation link that brought you here is invalid.",
|
||||
) in res.flashes
|
||||
user.reload()
|
||||
assert "new_email@mydomain.tld" not in user.emails
|
||||
|
||||
|
||||
def test_confirmation_email_already_confirmed_link(testclient, backend, user, admin):
|
||||
"""
|
||||
Clicking twice on a confirmation link should fail.
|
||||
"""
|
||||
email_confirmation = EmailConfirmationObject(
|
||||
"2020-01-01T01:00:00+00:00",
|
||||
"user",
|
||||
"john@doe.com",
|
||||
)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=email_confirmation.b64(),
|
||||
hash=email_confirmation.build_hash(),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get(email_confirmation_url)
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"This address email have already been confirmed.",
|
||||
) in res.flashes
|
||||
user.reload()
|
||||
assert "new_email@mydomain.tld" not in user.emails
|
||||
|
||||
|
||||
def test_confirmation_email_already_used_link(testclient, backend, user, admin):
|
||||
"""
|
||||
Confirmation link should fail if the target email is already associated
|
||||
to another account. For instance, if an administrator already put
|
||||
this email to someone else's profile.
|
||||
"""
|
||||
email_confirmation = EmailConfirmationObject(
|
||||
"2020-01-01T01:00:00+00:00",
|
||||
"user",
|
||||
"jane@doe.com",
|
||||
)
|
||||
email_confirmation_url = url_for(
|
||||
"account.email_confirmation",
|
||||
data=email_confirmation.b64(),
|
||||
hash=email_confirmation.build_hash(),
|
||||
_external=True,
|
||||
)
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get(email_confirmation_url)
|
||||
|
||||
assert (
|
||||
"error",
|
||||
"This address email is already associated with another account.",
|
||||
) in res.flashes
|
||||
user.reload()
|
||||
assert "new_email@mydomain.tld" not in user.emails
|
||||
|
||||
|
||||
def test_delete_email(testclient, logged_user):
|
||||
"""
|
||||
Tests that user can deletes its emails unless they have only
|
||||
one left.
|
||||
"""
|
||||
res = testclient.get("/profile/user")
|
||||
assert "email_remove" not in res.forms["emailconfirmationform"].fields
|
||||
|
||||
logged_user.emails = logged_user.emails + ["new@email.com"]
|
||||
logged_user.save()
|
||||
res = testclient.get("/profile/user")
|
||||
assert "email_remove" in res.forms["emailconfirmationform"].fields
|
||||
|
||||
res = res.forms["emailconfirmationform"].submit(
|
||||
name="email_remove", value="new@email.com"
|
||||
)
|
||||
assert res.flashes == [("success", "The email have been successfully deleted.")]
|
||||
|
||||
logged_user.reload()
|
||||
assert logged_user.emails == ["john@doe.com"]
|
||||
|
||||
|
||||
def test_delete_wrong_email(testclient, logged_user):
|
||||
"""
|
||||
Tests that removing an already removed email do not
|
||||
produce anything.
|
||||
"""
|
||||
logged_user.emails = logged_user.emails + ["new@email.com"]
|
||||
logged_user.save()
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
|
||||
res1 = res.forms["emailconfirmationform"].submit(
|
||||
name="email_remove", value="new@email.com"
|
||||
)
|
||||
assert res1.flashes == [("success", "The email have been successfully deleted.")]
|
||||
|
||||
res2 = res.forms["emailconfirmationform"].submit(
|
||||
name="email_remove", value="new@email.com"
|
||||
)
|
||||
assert res2.flashes == [("error", "Email deletion failed.")]
|
||||
|
||||
logged_user.reload()
|
||||
assert logged_user.emails == ["john@doe.com"]
|
||||
|
||||
|
||||
def test_delete_last_email(testclient, logged_user):
|
||||
"""
|
||||
Tests that users cannot remove their last email address.
|
||||
"""
|
||||
logged_user.emails = logged_user.emails + ["new@email.com"]
|
||||
logged_user.save()
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
|
||||
res1 = res.forms["emailconfirmationform"].submit(
|
||||
name="email_remove", value="new@email.com"
|
||||
)
|
||||
assert res1.flashes == [("success", "The email have been successfully deleted.")]
|
||||
|
||||
res2 = res.forms["emailconfirmationform"].submit(
|
||||
name="email_remove", value="john@doe.com"
|
||||
)
|
||||
assert res2.flashes == [("error", "Email deletion failed.")]
|
||||
|
||||
logged_user.reload()
|
||||
assert logged_user.emails == ["john@doe.com"]
|
||||
|
||||
|
||||
def test_edition_forced_mail(testclient, logged_user):
|
||||
"""
|
||||
Tests that users that must perform email verification
|
||||
cannot force the profile form.
|
||||
"""
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
form = res.forms["baseform"]
|
||||
testclient.post(
|
||||
"/profile/user",
|
||||
{
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
"emails-0": "new@email.com",
|
||||
"action": "edit-profile",
|
||||
},
|
||||
)
|
||||
|
||||
logged_user.reload()
|
||||
assert logged_user.emails == ["john@doe.com"]
|
||||
|
||||
|
||||
def test_invitation_form_mail_field_readonly(testclient):
|
||||
"""
|
||||
Tests that the email field is readonly in the invitation
|
||||
form creation if email confirmation is enabled.
|
||||
"""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = True
|
||||
|
||||
invitation = Invitation(
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"someoneelse",
|
||||
False,
|
||||
"someone@mydomain.tld",
|
||||
[],
|
||||
)
|
||||
hash = invitation.build_hash()
|
||||
b64 = invitation.b64()
|
||||
|
||||
res = testclient.get(f"/register/{b64}/{hash}")
|
||||
assert "readonly" in res.form["emails-0"].attrs
|
||||
|
||||
|
||||
def test_invitation_form_mail_field_writable(testclient):
|
||||
"""
|
||||
Tests that the email field is writable in the invitation
|
||||
form creation if email confirmation is disabled.
|
||||
"""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = False
|
||||
|
||||
invitation = Invitation(
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"someoneelse",
|
||||
False,
|
||||
"someone@mydomain.tld",
|
||||
[],
|
||||
)
|
||||
hash = invitation.build_hash()
|
||||
b64 = invitation.b64()
|
||||
|
||||
res = testclient.get(f"/register/{b64}/{hash}")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
|
@ -164,7 +164,7 @@ def test_registration(testclient, foo_group):
|
|||
[foo_group.id],
|
||||
)
|
||||
b64 = invitation.b64()
|
||||
hash = invitation.profile_hash()
|
||||
hash = invitation.build_hash()
|
||||
|
||||
testclient.get(f"/register/{b64}/{hash}", status=200)
|
||||
|
||||
|
@ -178,13 +178,13 @@ def test_registration_formcontrol(testclient):
|
|||
[],
|
||||
)
|
||||
b64 = invitation.b64()
|
||||
hash = invitation.profile_hash()
|
||||
hash = invitation.build_hash()
|
||||
|
||||
res = testclient.get(f"/register/{b64}/{hash}", status=200)
|
||||
assert "emails-1" not in res.form.fields
|
||||
|
||||
res = res.form.submit(status=200, name="fieldlist_add", value="emails-0")
|
||||
assert "emails-1" in res.form.fields
|
||||
res = res.form.submit(status=200, name="fieldlist_add", value="phone_numbers-0")
|
||||
assert "phone_numbers-1" in res.form.fields
|
||||
|
||||
|
||||
def test_registration_invalid_hash(testclient, foo_group):
|
||||
|
@ -205,7 +205,7 @@ def test_registration_invalid_data(testclient, foo_group):
|
|||
"someone@mydomain.tld",
|
||||
[foo_group.id],
|
||||
)
|
||||
hash = invitation.profile_hash()
|
||||
hash = invitation.build_hash()
|
||||
|
||||
testclient.get(f"/register/invalid/{hash}", status=302)
|
||||
|
||||
|
@ -221,7 +221,7 @@ def test_registration_more_than_48_hours_after_invitation(testclient, foo_group)
|
|||
"someone@mydomain.tld",
|
||||
[foo_group.id],
|
||||
)
|
||||
hash = invitation.profile_hash()
|
||||
hash = invitation.build_hash()
|
||||
b64 = invitation.b64()
|
||||
|
||||
testclient.get(f"/register/{b64}/{hash}", status=302)
|
||||
|
@ -235,7 +235,7 @@ def test_registration_no_password(testclient, foo_group):
|
|||
"someone@mydomain.tld",
|
||||
[foo_group.id],
|
||||
)
|
||||
hash = invitation.profile_hash()
|
||||
hash = invitation.build_hash()
|
||||
b64 = invitation.b64()
|
||||
url = f"/register/{b64}/{hash}"
|
||||
|
||||
|
@ -260,7 +260,7 @@ def test_no_registration_if_logged_in(testclient, logged_user, foo_group):
|
|||
"someone@mydomain.tld",
|
||||
[foo_group.id],
|
||||
)
|
||||
hash = invitation.profile_hash()
|
||||
hash = invitation.build_hash()
|
||||
b64 = invitation.b64()
|
||||
url = f"/register/{b64}/{hash}"
|
||||
|
||||
|
@ -298,7 +298,7 @@ def test_groups_are_saved_even_when_user_does_not_have_read_permission(
|
|||
[foo_group.id],
|
||||
)
|
||||
b64 = invitation.b64()
|
||||
hash = invitation.profile_hash()
|
||||
hash = invitation.build_hash()
|
||||
|
||||
res = testclient.get(f"/register/{b64}/{hash}", status=200)
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
from canaille.core.account import profile_hash
|
||||
from canaille.core.account import build_hash
|
||||
|
||||
|
||||
def test_password_reset(testclient, user):
|
||||
assert not user.check_password("foobarbaz")[0]
|
||||
hash = profile_hash("user", user.preferred_email, user.password[0])
|
||||
hash = build_hash("user", user.preferred_email, user.password[0])
|
||||
|
||||
res = testclient.get("/reset/user/" + hash, status=200)
|
||||
|
||||
|
@ -27,7 +27,7 @@ def test_password_reset_multiple_emails(testclient, user):
|
|||
user.save()
|
||||
|
||||
assert not user.check_password("foobarbaz")[0]
|
||||
hash = profile_hash("user", "foo@baz.com", user.password[0])
|
||||
hash = build_hash("user", "foo@baz.com", user.password[0])
|
||||
|
||||
res = testclient.get("/reset/user/" + hash, status=200)
|
||||
|
||||
|
@ -55,7 +55,7 @@ def test_password_reset_bad_link(testclient, user):
|
|||
|
||||
|
||||
def test_password_reset_bad_password(testclient, user):
|
||||
hash = profile_hash("user", user.preferred_email, user.password[0])
|
||||
hash = build_hash("user", user.preferred_email, user.password[0])
|
||||
|
||||
res = testclient.get("/reset/user/" + hash, status=200)
|
||||
|
||||
|
|
|
@ -1,7 +1,21 @@
|
|||
import pytest
|
||||
from canaille.core.populate import fake_users
|
||||
from webtest import Upload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configuration(configuration):
|
||||
configuration["EMAIL_CONFIRMATION"] = False
|
||||
return configuration
|
||||
|
||||
|
||||
def test_invalid_form_request(testclient, logged_user):
|
||||
res = testclient.get("/profile/user")
|
||||
res = res.forms["baseform"].submit(
|
||||
name="action", value="invalid-action", status=400
|
||||
)
|
||||
|
||||
|
||||
def test_user_list_pagination(testclient, logged_admin):
|
||||
res = testclient.get("/users")
|
||||
res.mustcontain("1 item")
|
||||
|
@ -101,24 +115,25 @@ def test_edition(
|
|||
jpeg_photo,
|
||||
):
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
res.form["given_name"] = "given_name"
|
||||
res.form["family_name"] = "family_name"
|
||||
res.form["display_name"] = "display_name"
|
||||
res.form["emails-0"] = "email@mydomain.tld"
|
||||
res.form["phone_numbers-0"] = "555-666-777"
|
||||
res.form["formatted_address"] = "formatted_address"
|
||||
res.form["street"] = "street"
|
||||
res.form["postal_code"] = "postal_code"
|
||||
res.form["locality"] = "locality"
|
||||
res.form["region"] = "region"
|
||||
res.form["employee_number"] = 666
|
||||
res.form["department"] = 1337
|
||||
res.form["title"] = "title"
|
||||
res.form["organization"] = "organization"
|
||||
res.form["preferred_language"] = "fr"
|
||||
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
form = res.forms["baseform"]
|
||||
form["given_name"] = "given_name"
|
||||
form["family_name"] = "family_name"
|
||||
form["display_name"] = "display_name"
|
||||
form["emails-0"] = "email@mydomain.tld"
|
||||
form["phone_numbers-0"] = "555-666-777"
|
||||
form["formatted_address"] = "formatted_address"
|
||||
form["street"] = "street"
|
||||
form["postal_code"] = "postal_code"
|
||||
form["locality"] = "locality"
|
||||
form["region"] = "region"
|
||||
form["employee_number"] = 666
|
||||
form["department"] = 1337
|
||||
form["title"] = "title"
|
||||
form["organization"] = "organization"
|
||||
form["preferred_language"] = "fr"
|
||||
form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [
|
||||
("success", "Le profil a été mis à jour avec succès.")
|
||||
], res.text
|
||||
|
@ -157,10 +172,11 @@ def test_edition_remove_fields(
|
|||
admin,
|
||||
):
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
res.form["display_name"] = ""
|
||||
res.form["phone_numbers-0"] = ""
|
||||
form = res.forms["baseform"]
|
||||
form["display_name"] = ""
|
||||
form["phone_numbers-0"] = ""
|
||||
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert res.flashes == [("success", "Profile updated successfully.")], res.text
|
||||
res = res.follow()
|
||||
|
||||
|
@ -189,14 +205,15 @@ def test_field_permissions_none(testclient, logged_user):
|
|||
}
|
||||
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
assert "phone_numbers-0" not in res.form.fields
|
||||
form = res.forms["baseform"]
|
||||
assert "phone_numbers-0" not in form.fields
|
||||
|
||||
testclient.post(
|
||||
"/profile/user",
|
||||
{
|
||||
"action": "edit",
|
||||
"action": "edit-profile",
|
||||
"phone_numbers-0": "000-000-000",
|
||||
"csrf_token": res.form["csrf_token"].value,
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
},
|
||||
)
|
||||
logged_user.reload()
|
||||
|
@ -214,14 +231,15 @@ def test_field_permissions_read(testclient, logged_user):
|
|||
"PERMISSIONS": ["edit_self"],
|
||||
}
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
assert "phone_numbers-0" in res.form.fields
|
||||
form = res.forms["baseform"]
|
||||
assert "phone_numbers-0" in form.fields
|
||||
|
||||
testclient.post(
|
||||
"/profile/user",
|
||||
{
|
||||
"action": "edit",
|
||||
"action": "edit-profile",
|
||||
"phone_numbers-0": "000-000-000",
|
||||
"csrf_token": res.form["csrf_token"].value,
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
},
|
||||
)
|
||||
logged_user.reload()
|
||||
|
@ -239,14 +257,15 @@ def test_field_permissions_write(testclient, logged_user):
|
|||
"PERMISSIONS": ["edit_self"],
|
||||
}
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
assert "phone_numbers-0" in res.form.fields
|
||||
form = res.forms["baseform"]
|
||||
assert "phone_numbers-0" in form.fields
|
||||
|
||||
testclient.post(
|
||||
"/profile/user",
|
||||
{
|
||||
"action": "edit",
|
||||
"action": "edit-profile",
|
||||
"phone_numbers-0": "000-000-000",
|
||||
"csrf_token": res.form["csrf_token"].value,
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
},
|
||||
)
|
||||
logged_user.reload()
|
||||
|
@ -255,15 +274,16 @@ def test_field_permissions_write(testclient, logged_user):
|
|||
|
||||
def test_simple_user_cannot_edit_other(testclient, admin, logged_user):
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
form = res.forms["baseform"]
|
||||
testclient.get("/profile/admin", status=404)
|
||||
testclient.post(
|
||||
"/profile/admin",
|
||||
{"action": "edit", "csrf_token": res.form["csrf_token"].value},
|
||||
{"action": "edit-profile", "csrf_token": form["csrf_token"].value},
|
||||
status=404,
|
||||
)
|
||||
testclient.post(
|
||||
"/profile/admin",
|
||||
{"action": "delete", "csrf_token": res.form["csrf_token"].value},
|
||||
{"action": "delete", "csrf_token": form["csrf_token"].value},
|
||||
status=404,
|
||||
)
|
||||
testclient.get("/users", status=403)
|
||||
|
@ -275,18 +295,20 @@ def test_admin_bad_request(testclient, logged_moderator):
|
|||
|
||||
def test_bad_email(testclient, logged_user):
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
form = res.forms["baseform"]
|
||||
|
||||
res.form["emails-0"] = "john@doe.com"
|
||||
form["emails-0"] = "john@doe.com"
|
||||
|
||||
res = res.form.submit(name="action", value="edit").follow()
|
||||
res = form.submit(name="action", value="edit-profile").follow()
|
||||
|
||||
assert ["john@doe.com"] == logged_user.emails
|
||||
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
form = res.forms["baseform"]
|
||||
|
||||
res.form["emails-0"] = "yolo"
|
||||
form["emails-0"] = "yolo"
|
||||
|
||||
res = res.form.submit(name="action", value="edit", status=200)
|
||||
res = form.submit(name="action", value="edit-profile", status=200)
|
||||
|
||||
logged_user.reload()
|
||||
|
||||
|
@ -295,11 +317,12 @@ def test_bad_email(testclient, logged_user):
|
|||
|
||||
def test_surname_is_mandatory(testclient, logged_user):
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
form = res.forms["baseform"]
|
||||
logged_user.family_name = ["Doe"]
|
||||
|
||||
res.form["family_name"] = ""
|
||||
form["family_name"] = ""
|
||||
|
||||
res = res.form.submit(name="action", value="edit", status=200)
|
||||
res = form.submit(name="action", value="edit-profile", status=200)
|
||||
|
||||
logged_user.reload()
|
||||
|
||||
|
@ -308,18 +331,21 @@ def test_surname_is_mandatory(testclient, logged_user):
|
|||
|
||||
def test_formcontrol(testclient, logged_user):
|
||||
res = testclient.get("/profile/user")
|
||||
assert "emails-1" not in res.form.fields
|
||||
form = res.forms["baseform"]
|
||||
assert "emails-1" not in form.fields
|
||||
|
||||
res = res.form.submit(status=200, name="fieldlist_add", value="emails-0")
|
||||
assert "emails-1" in res.form.fields
|
||||
res = form.submit(status=200, name="fieldlist_add", value="emails-0")
|
||||
form = res.forms["baseform"]
|
||||
assert "emails-1" in form.fields
|
||||
|
||||
|
||||
def test_formcontrol_htmx(testclient, logged_user):
|
||||
res = testclient.get("/profile/user")
|
||||
form = res.forms["baseform"]
|
||||
data = {
|
||||
field: res.form[field].value
|
||||
for field in res.form.fields
|
||||
if len(res.form.fields.get(field)) == 1
|
||||
field: form[field].value
|
||||
for field in form.fields
|
||||
if len(form.fields.get(field)) == 1
|
||||
}
|
||||
data["fieldlist_add"] = "emails-0"
|
||||
response = testclient.post(
|
||||
|
@ -336,11 +362,13 @@ def test_formcontrol_htmx(testclient, logged_user):
|
|||
|
||||
def test_inline_validation(testclient, logged_admin, user):
|
||||
res = testclient.get("/profile/admin")
|
||||
form = res.forms["baseform"]
|
||||
res = testclient.post(
|
||||
"/profile/admin",
|
||||
{
|
||||
"csrf_token": res.form["csrf_token"].value,
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
"emails-0": "john@doe.com",
|
||||
"action": "edit-profile",
|
||||
},
|
||||
headers={
|
||||
"HX-Request": "true",
|
||||
|
@ -358,11 +386,13 @@ def test_inline_validation_keep_indicators(
|
|||
configuration["ACL"]["ADMIN"]["WRITE"].append("display_name")
|
||||
|
||||
res = testclient.get("/profile/admin")
|
||||
form = res.forms["baseform"]
|
||||
res = testclient.post(
|
||||
"/profile/user",
|
||||
{
|
||||
"csrf_token": res.form["csrf_token"].value,
|
||||
"csrf_token": form["csrf_token"].value,
|
||||
"display_name": "George Abitbol",
|
||||
"action": "edit-profile",
|
||||
},
|
||||
headers={
|
||||
"HX-Request": "true",
|
||||
|
|
|
@ -54,9 +54,10 @@ def test_photo_on_profile_edition(
|
|||
):
|
||||
# Add a photo
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
res.form["photo_delete"] = False
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form = res.forms["baseform"]
|
||||
form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
form["photo_delete"] = False
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert ("success", "Profile updated successfully.") in res.flashes
|
||||
res = res.follow()
|
||||
|
||||
|
@ -66,8 +67,9 @@ def test_photo_on_profile_edition(
|
|||
|
||||
# No change
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
res.form["photo_delete"] = False
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form = res.forms["baseform"]
|
||||
form["photo_delete"] = False
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert ("success", "Profile updated successfully.") in res.flashes
|
||||
res = res.follow()
|
||||
|
||||
|
@ -77,8 +79,9 @@ def test_photo_on_profile_edition(
|
|||
|
||||
# Photo deletion
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
res.form["photo_delete"] = True
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form = res.forms["baseform"]
|
||||
form["photo_delete"] = True
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert ("success", "Profile updated successfully.") in res.flashes
|
||||
res = res.follow()
|
||||
|
||||
|
@ -88,9 +91,10 @@ def test_photo_on_profile_edition(
|
|||
|
||||
# Photo deletion AND upload, this should never happen
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
res.form["photo_delete"] = True
|
||||
res = res.form.submit(name="action", value="edit")
|
||||
form = res.forms["baseform"]
|
||||
form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
form["photo_delete"] = True
|
||||
res = form.submit(name="action", value="edit-profile")
|
||||
assert ("success", "Profile updated successfully.") in res.flashes
|
||||
res = res.follow()
|
||||
|
||||
|
@ -105,11 +109,14 @@ def test_photo_on_profile_creation(testclient, jpeg_photo, logged_admin):
|
|||
res.mustcontain(no="foobar")
|
||||
|
||||
res = testclient.get("/profile", status=200)
|
||||
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
res.form["user_name"] = "foobar"
|
||||
res.form["family_name"] = "Abitbol"
|
||||
res.form["emails-0"] = "george@abitbol.com"
|
||||
res = res.form.submit(name="action", value="edit", status=302).follow(status=200)
|
||||
form = res.forms["baseform"]
|
||||
form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
form["user_name"] = "foobar"
|
||||
form["family_name"] = "Abitbol"
|
||||
form["emails-0"] = "george@abitbol.com"
|
||||
res = form.submit(name="action", value="edit-profile", status=302).follow(
|
||||
status=200
|
||||
)
|
||||
|
||||
user = models.User.get_from_login("foobar")
|
||||
assert user.photo == [jpeg_photo]
|
||||
|
@ -122,12 +129,15 @@ def test_photo_deleted_on_profile_creation(testclient, jpeg_photo, logged_admin)
|
|||
res.mustcontain(no="foobar")
|
||||
|
||||
res = testclient.get("/profile", status=200)
|
||||
res.form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
res.form["photo_delete"] = True
|
||||
res.form["user_name"] = "foobar"
|
||||
res.form["family_name"] = "Abitbol"
|
||||
res.form["emails-0"] = "george@abitbol.com"
|
||||
res = res.form.submit(name="action", value="edit", status=302).follow(status=200)
|
||||
form = res.forms["baseform"]
|
||||
form["photo"] = Upload("logo.jpg", jpeg_photo)
|
||||
form["photo_delete"] = True
|
||||
form["user_name"] = "foobar"
|
||||
form["family_name"] = "Abitbol"
|
||||
form["emails-0"] = "george@abitbol.com"
|
||||
res = form.submit(name="action", value="edit-profile", status=302).follow(
|
||||
status=200
|
||||
)
|
||||
|
||||
user = models.User.get_from_login("foobar")
|
||||
assert user.photo == []
|
||||
|
|
Loading…
Reference in a new issue