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">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
{{ include_asset('src/entrypoints/shared.css') | indent(4) | safe }}
|
{{ include_asset('src/entrypoints/shared.css') | indent(4) | safe }}
|
||||||
{{ include_asset('src/entrypoints/templates.css') | indent(4) | safe }}
|
{{ include_asset('src/entrypoints/templates.css') | indent(4) | safe }}
|
||||||
|
{{ include_asset('src/entrypoints/templates.ts') | indent(4) | safe }}
|
||||||
{{ captcha.head() }}
|
{{ captcha.head() }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -19,6 +19,46 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
{%- if value %} value="{{ value }}" {% endif %}
|
{%- if value %} value="{{ value }}" {% endif %}
|
||||||
{%- endmacro %}
|
{%- 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) %}
|
{% macro field(label, name, form_state=false, class="", inline=false) %}
|
||||||
{% set field_id = new_id() %}
|
{% set field_id = new_id() %}
|
||||||
{% if not form_state %}
|
{% 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 %}
|
{% if field.errors is not empty %}
|
||||||
{% for error in field.errors %}
|
{% for error in field.errors %}
|
||||||
{% if error.kind != "unspecified" %}
|
{% if error.kind != "unspecified" %}
|
||||||
<div class="cpd-form-message cpd-form-error-message">
|
{{ error(error) }}
|
||||||
{% 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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
{% if features.password_registration %}
|
{% if features.password_registration %}
|
||||||
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
|
{% 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">
|
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
||||||
@username:{{ branding.server_name }}
|
@username:{{ branding.server_name }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||||
|
|
||||||
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
|
{% 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 %}
|
{% endcall %}
|
||||||
|
|
||||||
{% if features.password_registration_email_required %}
|
{% if features.password_registration_email_required %}
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% call(f) field.field(label=_("common.username"), name="username", form_state=form_state) %}
|
{% 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 %}
|
{% if f.errors is empty %}
|
||||||
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
|
||||||
|
|||||||
Reference in New Issue
Block a user