diff --git a/canaille/__init__.py b/canaille/__init__.py index efec1b2f..526de048 100644 --- a/canaille/__init__.py +++ b/canaille/__init__.py @@ -120,6 +120,8 @@ def global_processor(): "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) diff --git a/canaille/app/__init__.py b/canaille/app/__init__.py index 6517dba2..5c5182b8 100644 --- a/canaille/app/__init__.py +++ b/canaille/app/__init__.py @@ -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") diff --git a/canaille/config.sample.toml b/canaille/config.sample.toml index 0d66ac08..32df2a94 100644 --- a/canaille/config.sample.toml +++ b/canaille/config.sample.toml @@ -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 diff --git a/canaille/core/account.py b/canaille/core/account.py index 20d19dca..b08c052c 100644 --- a/canaille/core/account.py +++ b/canaille/core/account.py @@ -7,10 +7,10 @@ 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 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 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 @@ def has_expired(self): 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//") +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/", 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,19 +557,125 @@ 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() - flash(_("Profile updated successfully."), "success") - return redirect(url_for("account.profile_edition", edited_user=edited_user)) + + +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/", 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//settings", methods=("GET", "POST")) @@ -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 diff --git a/canaille/core/admin.py b/canaille/core/admin.py index 3fcd7e49..8bc2428f 100644 --- a/canaille/core/admin.py +++ b/canaille/core/admin.py @@ -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///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///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, + ) diff --git a/canaille/core/forms.py b/canaille/core/forms.py index 5ee91911..f7402397 100644 --- a/canaille/core/forms.py +++ b/canaille/core/forms.py @@ -284,7 +284,7 @@ def available_language_choices(): ) -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", + }, + ) diff --git a/canaille/core/mails.py b/canaille/core/mails.py index a08c6d12..17e58214 100644 --- a/canaille/core/mails.py +++ b/canaille/core/mails.py @@ -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, + ) diff --git a/canaille/templates/macro/form.html b/canaille/templates/macro/form.html index 1cc3b821..8ac7fb30 100644 --- a/canaille/templates/macro/form.html +++ b/canaille/templates/macro/form.html @@ -39,7 +39,7 @@
{% endif %} @@ -75,35 +75,37 @@ {% endif %} {% if field_visible %} - {% if del_button %} - - {% endif %} - {% if add_button %} - + hx-vals='{"fieldlist_remove": "{{ field.name }}"}' + hx-target="closest .fieldlist" + formnovalidate> + + + {% endif %} + {% if add_button and not readonly and not disabled %} + + {% endif %} {% endif %}
{% endif %} diff --git a/canaille/templates/mail/admin.html b/canaille/templates/mail/admin.html index 5401a8b4..48a273bc 100644 --- a/canaille/templates/mail/admin.html +++ b/canaille/templates/mail/admin.html @@ -98,6 +98,19 @@

+
+
+
+ TXT + HTML +
+
+
+ {{ _("Email verification") }} +
+
+ +
diff --git a/canaille/templates/mail/email-confirmation.html b/canaille/templates/mail/email-confirmation.html new file mode 100644 index 00000000..9d8023f7 --- /dev/null +++ b/canaille/templates/mail/email-confirmation.html @@ -0,0 +1,44 @@ + + + + + + + {{ title }} + + + + + + + + + + + + + + + + +
+

+ {% if logo %} + {{ site_name }} + {% endif %} +
+ {% trans %}Email address confirmation{% endtrans %} +
+

+
+ {% 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 %} +
+ {{ site_name }} + + {% trans %}Validate email address{% endtrans %} +
+ + diff --git a/canaille/templates/mail/email-confirmation.txt b/canaille/templates/mail/email-confirmation.txt new file mode 100644 index 00000000..3ecb4e35 --- /dev/null +++ b/canaille/templates/mail/email-confirmation.txt @@ -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 }} diff --git a/canaille/templates/profile_edit.html b/canaille/templates/profile_edit.html index 64af7298..63e14198 100644 --- a/canaille/templates/profile_edit.html +++ b/canaille/templates/profile_edit.html @@ -49,17 +49,17 @@

- {% call fui.render_form(form) %} - {% if "photo" in form %} + {% call fui.render_form(profile_form) %} + {% if "photo" in profile_form %}
{% 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] %}
{% endif %} + {% if "photo" in profile_form %}
{% 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 %}
- {% 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 %}
- {% 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 %}
- {% 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 %}
- {% 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 %}
@@ -168,5 +168,69 @@

{% endcall %} + + {% if emails_form %} +

+
+ {% if user.user_name == edited_user.user_name %} + {% trans %}My email addresses{% endtrans %} + {% else %} + {% trans %}Email addresses{% endtrans %} + {% endif %} +
+

+ + {% call fui.render_form(emails_form) %} +
+ {{ emails_form.old_emails.label() }} + {% for field in emails_form.old_emails %} +
+
+ {{ field(readonly=True) }} + {% if field.description %} + + {% endif %} + + {% if emails_form.old_emails|len > 1 %} + + {% endif %} +
+ +
+ {% endfor %} +
+ {% if emails_form.add_email_button %} + {% call fui.render_field(emails_form.new_email) %} + + {% endcall %} + {% endif %} + {% endcall %} + {% endif %} {% endblock %} diff --git a/demo/conf-docker/canaille.toml b/demo/conf-docker/canaille.toml index 79ccb164..1d0f5ac1 100644 --- a/demo/conf-docker/canaille.toml +++ b/demo/conf-docker/canaille.toml @@ -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 diff --git a/demo/conf/canaille.toml b/demo/conf/canaille.toml index cdc2564d..98f62df8 100644 --- a/demo/conf/canaille.toml +++ b/demo/conf/canaille.toml @@ -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 diff --git a/doc/configuration.rst b/doc/configuration.rst index ddf059b0..5591297a 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -57,6 +57,12 @@ Canaille is based on Flask, so any `flask configuration