diff --git a/frontend/src/entrypoints/templates.ts b/frontend/src/entrypoints/templates.ts new file mode 100644 index 000000000..6ec27f239 --- /dev/null +++ b/frontend/src/entrypoints/templates.ts @@ -0,0 +1,98 @@ +/* Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +// This script includes some optional javascript used in the server-side +// generated templates which enhances the user experience if loaded. +// +// Ideally later on we could find a way to hydrate full React components instead +// of doing this, as this can very quickly get out of hands. + +const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/-]+|@[a-z0-9.=_/-]+(:.*)?)\s*$/g; + +/** Grab the nearest error message inserted by the templates by error kind and code */ +function grabErrorMessage( + parentElement: HTMLElement | null, + kind: string, + code: string, +): HTMLElement | null { + if (!parentElement) return null; + const matching = parentElement.querySelectorAll( + `[data-error-kind=${kind}][data-error-code=${code}]`, + ); + // We potentially have duplicate error messages coming from the templates, one + // hidden and one visible + let el: HTMLElement | null = null; + for (const element of matching) { + // In case we're finding a non-hidden element, we prioritise that one + if (!element.classList.contains("hidden")) return element; + // Else it will be the last element in the list + el = element; + } + return el; +} + +/** + * This patches a username input element to lowercase on input and trim on blur + * + * @param inputElement The input element to patch + */ +function patchUsernameInput(inputElement: HTMLInputElement) { + // Exclude readonly/disabled inputs + if (inputElement.readOnly || inputElement.disabled) return; + + const labelElement = inputElement.parentElement?.querySelector("label"); + // This is the list of elements which needs to have the data-invalid attribute + // set/unset + const fieldElements: HTMLElement[] = [inputElement]; + if (labelElement) fieldElements.push(labelElement); + + // Grab the translated 'invalid username' message from the DOM + // TODO: we could expand this to other validation messages, but this is the + // most important one for now + const invalidUsernameMessage = grabErrorMessage( + inputElement.parentElement, + "policy", + "username-invalid-chars", + ); + if (!invalidUsernameMessage) { + console.warn( + "Could not find the error message in the DOM for username validation", + inputElement, + ); + } + + inputElement.addEventListener("input", function () { + // Simply lowercase things automatically, as this is not too disruptive + inputElement.value = inputElement.value.toLocaleLowerCase(); + + const match = inputElement.value.match(VALID_USERNAME_RE); + if (!inputElement.value.trim() || match !== null) { + // Remove the data-invalid attribute from all elements + for (const el of fieldElements) el.removeAttribute("data-invalid"); + + // Hide the error message + invalidUsernameMessage?.classList.add("hidden"); + } else { + // Set the data-invalid attribute on all elements + for (const el of fieldElements) el.setAttribute("data-invalid", ""); + + // Show the error message + invalidUsernameMessage?.classList.remove("hidden"); + } + }); + + // Sneakily trim the input on blur + inputElement.addEventListener("blur", function () { + inputElement.value = inputElement.value.trim(); + }); +} + +// Look for username inputs on the page and patch them +for (const element of document.querySelectorAll( + "input[data-choose-username]", +)) { + patchUsernameInput(element); +} diff --git a/templates/base.html b/templates/base.html index 1500567b4..cfa191ff2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -25,6 +25,7 @@ Please see LICENSE files in the repository root for full details. {{ include_asset('src/entrypoints/shared.css') | indent(4) | safe }} {{ include_asset('src/entrypoints/templates.css') | indent(4) | safe }} + {{ include_asset('src/entrypoints/templates.ts') | indent(4) | safe }} {{ captcha.head() }} diff --git a/templates/components/field.html b/templates/components/field.html index 4ad59f44c..5477dd588 100644 --- a/templates/components/field.html +++ b/templates/components/field.html @@ -19,6 +19,46 @@ Please see LICENSE files in the repository root for full details. {%- if value %} value="{{ value }}" {% endif %} {%- endmacro %} +{% macro error(error, hidden=false) %} + +{% endmacro %} + {% macro field(label, name, form_state=false, class="", inline=false) %} {% set field_id = new_id() %} {% if not form_state %} @@ -55,41 +95,7 @@ Please see LICENSE files in the repository root for full details. {% if field.errors is not empty %} {% for error in field.errors %} {% if error.kind != "unspecified" %} -
- {% if error.kind == "required" %} - {{ _("mas.errors.field_required") }} - {% elif error.kind == "exists" and field.name == "username" %} - {{ _("mas.errors.username_taken") }} - {% elif error.kind == "policy" %} - {% if error.code == "username-too-short" %} - {{ _("mas.errors.username_too_short") }} - {% elif error.code == "username-too-long" %} - {{ _("mas.errors.username_too_long") }} - {% elif error.code == "username-invalid-chars" %} - {{ _("mas.errors.username_invalid_chars") }} - {% elif error.code == "username-all-numeric" %} - {{ _("mas.errors.username_all_numeric") }} - {% elif error.code == "username-banned" %} - {{ _("mas.errors.username_banned") }} - {% elif error.code == "username-not-allowed" %} - {{ _("mas.errors.username_not_allowed") }} - {% elif error.code == "email-domain-not-allowed" %} - {{ _("mas.errors.email_domain_not_allowed") }} - {% elif error.code == "email-domain-banned" %} - {{ _("mas.errors.email_domain_banned") }} - {% elif error.code == "email-not-allowed" %} - {{ _("mas.errors.email_not_allowed") }} - {% elif error.code == "email-banned" %} - {{ _("mas.errors.email_banned") }} - {% else %} - {{ _("mas.errors.denied_policy", policy=error.message) }} - {% endif %} - {% elif error.kind == "password_mismatch" %} - {{ _("mas.errors.password_mismatch") }} - {% else %} - {{ error.kind }} - {% endif %} -
+ {{ error(error) }} {% endif %} {% endfor %} {% endif %} diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index 5115b7bbb..dc9fd61ec 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -28,7 +28,7 @@ Please see LICENSE files in the repository root for full details. {% if features.password_registration %} {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - +
@username:{{ branding.server_name }}
diff --git a/templates/pages/register/password.html b/templates/pages/register/password.html index 82aa763fd..f1a77efad 100644 --- a/templates/pages/register/password.html +++ b/templates/pages/register/password.html @@ -32,7 +32,8 @@ Please see LICENSE files in the repository root for full details. {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - + + {{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }} {% endcall %} {% if features.password_registration_email_required %} diff --git a/templates/pages/upstream_oauth2/do_register.html b/templates/pages/upstream_oauth2/do_register.html index f57564c43..bab1d340e 100644 --- a/templates/pages/upstream_oauth2/do_register.html +++ b/templates/pages/upstream_oauth2/do_register.html @@ -99,7 +99,8 @@ Please see LICENSE files in the repository root for full details. {% endcall %} {% else %} {% call(f) field.field(label=_("common.username"), name="username", form_state=form_state) %} - + + {{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }} {% if f.errors is empty %}