diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c21dbb75..81802c83 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -99,6 +99,8 @@ With the LDAP backend, all data is lost when Canaille stops. cd demo docker compose --file docker-compose-ldap.yml up +.. _local_environment: + Local environment ~~~~~~~~~~~~~~~~~ diff --git a/canaille/app/forms.py b/canaille/app/forms.py index ddfc4dee..fc568352 100644 --- a/canaille/app/forms.py +++ b/canaille/app/forms.py @@ -262,7 +262,13 @@ class BaseForm(HTMXFormMixin, I18NFormMixin, wtforms.form.BaseForm): class TableForm(I18NFormMixin, FlaskForm): - def __init__(self, cls=None, page_size=25, fields=None, filter=None, **kwargs): + """ + A form for table rendering of object collections. + """ + + def __init__( + self, cls=None, page_size: int = 25, fields=None, filter=None, **kwargs + ): filter = filter or {} super().__init__(**kwargs) if self.query.data: diff --git a/canaille/core/endpoints/account.py b/canaille/core/endpoints/account.py index 76680ceb..df114187 100644 --- a/canaille/core/endpoints/account.py +++ b/canaille/core/endpoints/account.py @@ -331,8 +331,6 @@ def registration(data=None, hash=None): "core/profile_add.html", form=form, menuitem="users", - edited_user=None, - self_deletion=False, ) if not form.validate(): @@ -341,8 +339,6 @@ def registration(data=None, hash=None): "core/profile_add.html", form=form, menuitem="users", - edited_user=None, - self_deletion=False, ) user = profile_create(current_app, form) @@ -424,8 +420,6 @@ def profile_creation(user): "core/profile_add.html", form=form, menuitem="users", - edited_user=None, - self_deletion=False, ) if not form.validate(): @@ -434,8 +428,6 @@ def profile_creation(user): "core/profile_add.html", form=form, menuitem="users", - edited_user=None, - self_deletion=False, ) user = profile_create(current_app, form) diff --git a/canaille/core/endpoints/auth.py b/canaille/core/endpoints/auth.py index 7f67ca4c..1d794cbf 100644 --- a/canaille/core/endpoints/auth.py +++ b/canaille/core/endpoints/auth.py @@ -301,7 +301,7 @@ def setup_two_factor_auth(): "core/setup-2fa.html", secret=user.secret_token, qr_image=base64_qr_image, - username=user.user_name, + user=user, ) diff --git a/canaille/core/endpoints/forms.py b/canaille/core/endpoints/forms.py index 5a019ee9..d45d2173 100644 --- a/canaille/core/endpoints/forms.py +++ b/canaille/core/endpoints/forms.py @@ -18,77 +18,16 @@ from canaille.app.forms import password_too_long_validator from canaille.app.forms import phone_number from canaille.app.forms import set_readonly from canaille.app.forms import unique_values -from canaille.app.i18n import gettext from canaille.app.i18n import lazy_gettext as _ from canaille.app.i18n import native_language_name_from_code from canaille.backends import Backend from canaille.core.models import OTP_DIGITS - - -def unique_user_name(form, field): - if Backend.instance.get(models.User, user_name=field.data) and ( - not getattr(form, "user", None) or form.user.user_name != field.data - ): - raise wtforms.ValidationError( - _("The user name '{user_name}' already exists").format(user_name=field.data) - ) - - -def unique_email(form, field): - if Backend.instance.get(models.User, emails=field.data) and ( - not getattr(form, "user", None) or field.data not in form.user.emails - ): - raise wtforms.ValidationError( - _("The email '{email}' is already used").format(email=field.data) - ) - - -def unique_group(form, field): - if Backend.instance.get(models.Group, display_name=field.data): - raise wtforms.ValidationError( - _("The group '{group}' already exists").format(group=field.data) - ) - - -def existing_login(form, field): - if not current_app.config["CANAILLE"][ - "HIDE_INVALID_LOGINS" - ] and not Backend.instance.get_user_from_login(field.data): - raise wtforms.ValidationError( - _("The login '{login}' does not exist").format(login=field.data) - ) - - -def existing_group_member(form, field): - if field.data is None: - raise wtforms.ValidationError( - gettext("The user you are trying to remove does not exist.") - ) - - if field.data not in form.group.members: - raise wtforms.ValidationError( - gettext( - "The user '{user}' has already been removed from the group '{group}'" - ).format(user=field.data.formatted_name, group=form.group.display_name) - ) - - -def non_empty_groups(form, field): - """LDAP groups cannot be empty because groupOfNames.member is a MUST - attribute. - - https://www.rfc-editor.org/rfc/rfc2256.html#section-7.10 - """ - if not form.user: - return - - for group in form.user.groups: - if len(group.members) == 1 and group not in field.data: - raise wtforms.ValidationError( - _( - "The group '{group}' cannot be removed, because it must have at least one user left." - ).format(group=group.display_name) - ) +from canaille.core.validators import existing_group_member +from canaille.core.validators import existing_login +from canaille.core.validators import non_empty_groups +from canaille.core.validators import unique_email +from canaille.core.validators import unique_group +from canaille.core.validators import unique_user_name class LoginForm(Form): @@ -365,6 +304,8 @@ def build_profile_form(write_field_names, readonly_field_names, user=None): class CreateGroupForm(Form): + """The group creation form.""" + display_name = wtforms.StringField( _("Name"), validators=[wtforms.validators.DataRequired(), unique_group], @@ -379,6 +320,8 @@ class CreateGroupForm(Form): class EditGroupForm(Form): + """The group edition form.""" + display_name = wtforms.StringField( _("Name"), validators=[ @@ -422,6 +365,8 @@ class JoinForm(Form): class InvitationForm(Form): + """The user invitation form.""" + user_name = wtforms.StringField( _("User name"), render_kw={"placeholder": _("jdoe")}, diff --git a/canaille/core/validators.py b/canaille/core/validators.py new file mode 100644 index 00000000..cfffa06e --- /dev/null +++ b/canaille/core/validators.py @@ -0,0 +1,74 @@ +import wtforms.form +import wtforms.validators +from flask import current_app + +from canaille.app import models +from canaille.app.i18n import gettext +from canaille.app.i18n import lazy_gettext as _ +from canaille.backends import Backend + + +def unique_user_name(form, field): + if Backend.instance.get(models.User, user_name=field.data) and ( + not getattr(form, "user", None) or form.user.user_name != field.data + ): + raise wtforms.ValidationError( + _("The user name '{user_name}' already exists").format(user_name=field.data) + ) + + +def unique_email(form, field): + if Backend.instance.get(models.User, emails=field.data) and ( + not getattr(form, "user", None) or field.data not in form.user.emails + ): + raise wtforms.ValidationError( + _("The email '{email}' is already used").format(email=field.data) + ) + + +def unique_group(form, field): + if Backend.instance.get(models.Group, display_name=field.data): + raise wtforms.ValidationError( + _("The group '{group}' already exists").format(group=field.data) + ) + + +def existing_login(form, field): + if not current_app.config["CANAILLE"][ + "HIDE_INVALID_LOGINS" + ] and not Backend.instance.get_user_from_login(field.data): + raise wtforms.ValidationError( + _("The login '{login}' does not exist").format(login=field.data) + ) + + +def existing_group_member(form, field): + if field.data is None: + raise wtforms.ValidationError( + gettext("The user you are trying to remove does not exist.") + ) + + if field.data not in form.group.members: + raise wtforms.ValidationError( + gettext( + "The user '{user}' has already been removed from the group '{group}'" + ).format(user=field.data.formatted_name, group=form.group.display_name) + ) + + +def non_empty_groups(form, field): + """LDAP groups cannot be empty because groupOfNames.member is a MUST + attribute. + + https://www.rfc-editor.org/rfc/rfc2256.html#section-7.10 + """ + if not form.user: + return + + for group in form.user.groups: + if len(group.members) == 1 and group not in field.data: + raise wtforms.ValidationError( + _( + "The group '{group}' cannot be removed, because it must have at least one user left." + ).format(group=group.display_name) + ) diff --git a/canaille/oidc/endpoints/consents.py b/canaille/oidc/endpoints/consents.py index d497c3e9..35b0ad4b 100644 --- a/canaille/oidc/endpoints/consents.py +++ b/canaille/oidc/endpoints/consents.py @@ -61,6 +61,7 @@ def pre_consents(user): "oidc/preconsent_list.html", menuitem="consents", scope_details=SCOPE_DETAILS, + # TODO: do not delegate this var to the templates, or set this explicitly in the templates. ignored_scopes=["openid"], preconsented=preconsented, nb_consents=nb_consents, diff --git a/canaille/oidc/endpoints/forms.py b/canaille/oidc/endpoints/forms.py index ccba4e7f..03eb573d 100644 --- a/canaille/oidc/endpoints/forms.py +++ b/canaille/oidc/endpoints/forms.py @@ -18,7 +18,7 @@ class LogoutForm(Form): answer = wtforms.SubmitField() -def client_audiences(): +def _client_audiences(): return [ (client, client.client_name) for client in Backend.instance.query(models.Client) ] @@ -111,7 +111,7 @@ class ClientAddForm(Form): audience = wtforms.SelectMultipleField( _("Token audiences"), validators=[wtforms.validators.Optional()], - choices=client_audiences, + choices=_client_audiences, validate_choice=False, coerce=IDToModel("Client"), ) diff --git a/canaille/templates/base.html b/canaille/templates/base.html index c1e1c93c..2cf02a3f 100644 --- a/canaille/templates/base.html +++ b/canaille/templates/base.html @@ -1,3 +1,25 @@ +{# +The main template inherited by almost every other templates. + +:param user: The current user, if logged in. +:type user: :class:`~canaille.core.models.User` +:param features: The features enabled and available in Canaille. +:type features: :class:`~canaille.app.features.Features` +:param locale: The user locale. +:type locale: :class:`str` +:param website_name: The Canaille instance defined in :attr:`~canaille.core.configuration.CoreSettings.NAME`. +:type website_name: :class:`str` +:param logo_url: The URL of the instance logo defined in :attr:`~canaille.core.configuration.CoreSettings.LOGO`. +:type logo_url: :class:`str` +:param favicon_url: The URL of the instance favicon defined in :attr:`~canaille.core.configuration.CoreSettings.FAVICON`. +:type favicon_url: :class:`str` +:param is_boosted: Whether the page is boosted by :attr:`~canaille.core.configuration.CoreSettings.HTMX`. +:type is_boosted: :class:`bool` +:param menu: Whether to display the menu or not. +:type menu: :class:`bool` +:param debug: Whether the app has been launched in debug mode. +:type debug: :class:`bool` +#} {%- import 'macro/flask.html' as flask -%} diff --git a/canaille/templates/core/about.html b/canaille/templates/core/about.html index 642c67e3..b880509e 100644 --- a/canaille/templates/core/about.html +++ b/canaille/templates/core/about.html @@ -1,3 +1,11 @@ +{# +The 'About' page. +This is an informational page, displaying the project links. + +:param version: The current Canaille version. +:type version: :class:`str` +#} + {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} diff --git a/canaille/templates/core/firstlogin.html b/canaille/templates/core/firstlogin.html index 4ea80b9e..f1f6eae4 100644 --- a/canaille/templates/core/firstlogin.html +++ b/canaille/templates/core/firstlogin.html @@ -1,3 +1,7 @@ +{# The first login page. + +This page is displayed to users who do not have set a password yet. +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} diff --git a/canaille/templates/core/forgotten-password.html b/canaille/templates/core/forgotten-password.html index a116f871..ebffe472 100644 --- a/canaille/templates/core/forgotten-password.html +++ b/canaille/templates/core/forgotten-password.html @@ -1,3 +1,7 @@ +{# Password forgotten page. + +This page displays a form asking for the email address of users who cannot remember their password. +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} diff --git a/canaille/templates/core/group.html b/canaille/templates/core/group.html index 506207a6..56c91d5e 100644 --- a/canaille/templates/core/group.html +++ b/canaille/templates/core/group.html @@ -1,3 +1,12 @@ +{# Group edition page. + +Displays the group edition or creation form. + +:param edited_group: :data:`None` in a creation context. In edition context this is the edited group. +:type edited_group: :class:`~canaille.core.models.Group` +:param form: The group edition/creation form. +:type form: :class:`~canaille.core.endpoints.forms.CreateGroupForm` or :class:`~canaille.core.endpoints.forms.EditGroupForm` +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} {% import "macro/table.html" as table %} diff --git a/canaille/templates/core/groups.html b/canaille/templates/core/groups.html index 5ccc5611..263fb0cd 100644 --- a/canaille/templates/core/groups.html +++ b/canaille/templates/core/groups.html @@ -1,3 +1,8 @@ +{# The group list page. + +:param table: A :class:`~canaille.core.models.Group` pagination form. +:type table: :class:`~canaille.app.forms.TableForm` +#} {% extends theme('base.html') %} {% import "macro/table.html" as table %} diff --git a/canaille/templates/core/invite.html b/canaille/templates/core/invite.html index 3538f5a8..48d135ee 100644 --- a/canaille/templates/core/invite.html +++ b/canaille/templates/core/invite.html @@ -1,3 +1,10 @@ +{# The invitation form page. + +Displays the invitation form to users with the invitation permission. + +:param form: The invitation form. +:type form: :class:`~canaille.core.endpoints.forms.InvitationForm` +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} diff --git a/canaille/templates/core/join.html b/canaille/templates/core/join.html index a37a6873..a2a300e7 100644 --- a/canaille/templates/core/join.html +++ b/canaille/templates/core/join.html @@ -1,3 +1,11 @@ +{# The invitation acceptation page. + +This page is displayed to users who have clicked on invitation links sent by mail (or by other media). +It displays a basic account creation form. + +:param form: The account creation form. +:type form: :class:`~canaille.core.endpoints.forms.JoinForm` +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} {% import 'core/partial/profile_field.html' as profile %} diff --git a/canaille/templates/core/login.html b/canaille/templates/core/login.html index bc353e7e..2e44f8b6 100644 --- a/canaille/templates/core/login.html +++ b/canaille/templates/core/login.html @@ -1,3 +1,10 @@ +{# The login page. + +This page displays a form to get the user identifier. + +:param form: The login form. +:type form: :class:`~canaille.core.endpoints.forms.LoginForm` +#} {% extends theme('base.html') %} {% import 'macro/flask.html' as flask %} {% import 'macro/form.html' as fui %} diff --git a/canaille/templates/core/profile_add.html b/canaille/templates/core/profile_add.html index 58fd8cbc..19209749 100644 --- a/canaille/templates/core/profile_add.html +++ b/canaille/templates/core/profile_add.html @@ -1,3 +1,11 @@ +{# User account creation page. + +This template displays an account creation form. +It is used in the registration page, and in the manual account creation page available for users with *user management* permission. + +:param form: The user creation form. Dynamically built according to the user :attr:`~canaille.core.configuration.ACLSettings.READ` and :attr:`~canaille.core.configuration.ACLSettings.WRITE` permissions. The available fields are those appearing in *READ* and *WRITE*, those only appearing in *READ* are read-only. +:type form: :class:`~flask_wtf.FlaskForm` +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} {% import 'core/partial/profile_field.html' as profile %} diff --git a/canaille/templates/core/profile_edit.html b/canaille/templates/core/profile_edit.html index a825de4f..1bae172b 100644 --- a/canaille/templates/core/profile_edit.html +++ b/canaille/templates/core/profile_edit.html @@ -1,3 +1,15 @@ +{# The profile edition template. + +Displays a user profile edition form. + +:param edited_user: The user that the form will edit. +:type edited_user: :class:`~canaille.core.models.User` +:param profile_form: The user profile edition form. Dynamically built according to the user :attr:`~canaille.core.configuration.ACLSettings.READ` and :attr:`~canaille.core.configuration.ACLSettings.WRITE` permissions. The available fields are those appearing in *READ* and *WRITE*, those only appearing in *READ* are read-only. +:type profile_form: :class:`~flask_wtf.FlaskForm` +:param emails_form: An email edition form. Used when the :attr:`~canaille.app.features.Features.has_email_confirmation` feature is enabled. +:type emails_form: :class:`~canaille.core.endpoints.forms.EmailConfirmationForm` +#} + {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} {% import 'core/partial/profile_field.html' as profile %} diff --git a/canaille/templates/core/profile_settings.html b/canaille/templates/core/profile_settings.html index 7b74caa8..8032f78b 100644 --- a/canaille/templates/core/profile_settings.html +++ b/canaille/templates/core/profile_settings.html @@ -1,3 +1,14 @@ +{# The profile settings template. + +Displays the user settings edition form. + +:param edited_user: The user that the form will edit. +:type edited_user: :class:`~canaille.core.models.User` +:param form: The user profile edition form. Dynamically built according to the user :attr:`~canaille.core.configuration.ACLSettings.READ` and :attr:`~canaille.core.configuration.ACLSettings.WRITE` permissions. The available fields are those appearing in *READ* and *WRITE*, those only appearing in *READ* are read-only. +:type form: :class:`~flask_wtf.FlaskForm` +:param self_deletion: Whether the editor is allowed to delete the account of the edited user. +:type self_deletion: :class:`bool` +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} diff --git a/canaille/templates/core/reset-password.html b/canaille/templates/core/reset-password.html index 8289412e..4a0c721e 100644 --- a/canaille/templates/core/reset-password.html +++ b/canaille/templates/core/reset-password.html @@ -1,3 +1,14 @@ +{# The password reset template. + +Displays a password reset form. + +:param form: The password reset form. +:type form: :class:`~canaille.core.endpoints.forms.PasswordResetForm` +:param user: The user associated with the URL. +:type user: :class:`~canaille.core.models.User` +:param hash: The secret link hash. +:type hash: :class:`str` +#} {% extends theme('base.html') %} {% import 'macro/form.html' as fui %} @@ -15,7 +26,7 @@ {% endblock %}
- {{ fui.render_form(form, _("Password reset"), action=url_for("core.auth.reset", user=user, hash=hash)) }} + {{ fui.render_form(form, _("Password reset")) }}
{% endblock %} diff --git a/canaille/templates/core/setup-2fa.html b/canaille/templates/core/setup-2fa.html index ea535715..3c7a0e8f 100644 --- a/canaille/templates/core/setup-2fa.html +++ b/canaille/templates/core/setup-2fa.html @@ -1,3 +1,14 @@ +{# The multi-factor authentication initialization template. + +Display a QR-code and the OTP secret. + +:param user: The user initializing the OTP. +:type user: :class:`~canaille.core.models.User` +:param secret: The OTP secret. +:type secret: :class:`str` +:param qr_image: A QR-code image representing the OTP secret. +:type qr_image: A base64 encoded :class:`str` +#} {% extends theme('base.html') %} {% import 'macro/flask.html' as flask %} {% import 'macro/form.html' as fui %} @@ -18,7 +29,7 @@

- {{ _("Sign in as %(username)s", username=username) }} + {{ _("Sign in as %(username)s", username=user.user_name) }}
{% trans %}Set up multi-factor authentication.{% endtrans %}

diff --git a/canaille/templates/core/users.html b/canaille/templates/core/users.html index 07a60933..dda8f5aa 100644 --- a/canaille/templates/core/users.html +++ b/canaille/templates/core/users.html @@ -1,3 +1,10 @@ +{# The users list. + +Displays a paginated list of :class:`~canaille.core.models.User`. + +:param table_form: The paginated list form. +:type table_form: :class:`~canaille.app.forms.TableForm` of :class:`~canaille.core.models.User`. +#} {% extends theme('base.html') %} {% import "macro/table.html" as table %} diff --git a/canaille/templates/core/verify-2fa.html b/canaille/templates/core/verify-2fa.html index c30aec12..4f84ed00 100644 --- a/canaille/templates/core/verify-2fa.html +++ b/canaille/templates/core/verify-2fa.html @@ -1,3 +1,14 @@ +{# The multi-factor authentication code verification template. + +Displays a form that asks for the multi-factor authentication code. + +:param form: The code verification form. +:type form: :class:`~canaille.core.endpoints.forms.TwoFactorForm` +:param username: The username of the user attempting to log-in. +:type username: :class:`str` +:param method: The authentication factor method. +:type method: :class:`str` (*TOTP*, *HOTP*, *EMAIL_OTP*, *SMS_OTP*) +#} {% extends theme('base.html') %} {% import 'macro/flask.html' as flask %} {% import 'macro/form.html' as fui %} diff --git a/canaille/templates/error.html b/canaille/templates/error.html index 45e73918..935407fa 100644 --- a/canaille/templates/error.html +++ b/canaille/templates/error.html @@ -1,3 +1,13 @@ +{# +The error page. Displayed for all kinds of errors (not found, internal server error etc.). + +:param error_code: The code of the HTTP error (404, 500, etc.) +:type error_code: :class:`int` +:param description: The error code description. +:type description: :class:`str` +:param icon: An optional Font Awesome icon reference. +:type icon: :class:`str` +#} {% extends theme('base.html') %} {% block content %} diff --git a/canaille/templates/macro/flask.html b/canaille/templates/macro/flask.html index 568c46f1..0259b7e5 100644 --- a/canaille/templates/macro/flask.html +++ b/canaille/templates/macro/flask.html @@ -1,3 +1,4 @@ +{# Macros for Flask flash message rendering #} {% macro messages() %} {% with messages = get_flashed_messages(with_categories=true) %} {% for category, message in messages %} diff --git a/canaille/templates/macro/form.html b/canaille/templates/macro/form.html index 620ca105..a533ff51 100644 --- a/canaille/templates/macro/form.html +++ b/canaille/templates/macro/form.html @@ -1,3 +1,7 @@ +{# Macros for form and form field rendering. + +Connects WTForms, Fomantic-UI and HTMX. +#} {% macro render_input( field, label_visible=true, diff --git a/canaille/templates/macro/table.html b/canaille/templates/macro/table.html index a94df7ca..8a749c31 100644 --- a/canaille/templates/macro/table.html +++ b/canaille/templates/macro/table.html @@ -1,3 +1,6 @@ +{# +Macros for rendering table paginated with HTMX. +#} {% macro search(form, target) %}