Interactively guide users for selecting a valid username

This commit is contained in:
Quentin Gliech
2025-11-18 18:25:23 +01:00
parent 95376dab81
commit f0d779e9b1
6 changed files with 145 additions and 38 deletions

View 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);
}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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">