Interactively guide users for selecting a valid username
This commit is contained in:
98
frontend/src/entrypoints/templates.ts
Normal file
98
frontend/src/entrypoints/templates.ts
Normal file
@@ -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<HTMLElement>(
|
||||
`[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<HTMLInputElement>(
|
||||
"input[data-choose-username]",
|
||||
)) {
|
||||
patchUsernameInput(element);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{{ 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() }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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) %}
|
||||
<div class="cpd-form-message cpd-form-error-message
|
||||
{%- if hidden %} hidden{% endif %}" data-error-kind="{{ error.kind }}"
|
||||
{%- if error.code %}data-error-code="{{ error.code }}"{% endif %}>
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% 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" %}
|
||||
<div class="cpd-form-message cpd-form-error-message">
|
||||
{% 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 %}
|
||||
</div>
|
||||
{{ error(error) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -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) %}
|
||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" />
|
||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" data-choose-username />
|
||||
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
||||
@username:{{ branding.server_name }}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||
|
||||
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
|
||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required />
|
||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required data-choose-username />
|
||||
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
|
||||
{% endcall %}
|
||||
|
||||
{% if features.password_registration_email_required %}
|
||||
|
||||
@@ -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) %}
|
||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" value="{{ imported_localpart or '' }}" aria-describedby="{{ f.id }}-help" />
|
||||
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" value="{{ imported_localpart or '' }}" aria-describedby="{{ f.id }}-help" data-choose-username />
|
||||
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
|
||||
|
||||
{% if f.errors is empty %}
|
||||
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
||||
|
||||
Reference in New Issue
Block a user