Skip to content

Commit f0b408e

Browse files
committed
Interactively guide users for selecting a valid username
1 parent 2caa6e7 commit f0b408e

File tree

6 files changed

+145
-38
lines changed

6 files changed

+145
-38
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/* Copyright 2025 New Vector Ltd.
2+
*
3+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
* Please see LICENSE files in the repository root for full details.
5+
*/
6+
7+
// This script includes some optional javascript used in the server-side
8+
// generated templates which enhances the user experience if loaded.
9+
//
10+
// Ideally later on we could find a way to hydrate full React components instead
11+
// of doing this, as this can very quickly get out of hands.
12+
13+
const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/-]+|@[a-z0-9.=_/-]+(:.*)?)\s*$/g;
14+
15+
/** Grab the nearest error message inserted by the templates by error kind and code */
16+
function grabErrorMessage(
17+
parentElement: HTMLElement | null,
18+
kind: string,
19+
code: string,
20+
): HTMLElement | null {
21+
if (!parentElement) return null;
22+
const matching = parentElement.querySelectorAll<HTMLElement>(
23+
`[data-error-kind=${kind}][data-error-code=${code}]`,
24+
);
25+
// We potentially have duplicate error messages coming from the templates, one
26+
// hidden and one visible
27+
let el: HTMLElement | null = null;
28+
for (const element of matching) {
29+
// In case we're finding a non-hidden element, we prioritise that one
30+
if (!element.classList.contains("hidden")) return element;
31+
// Else it will be the last element in the list
32+
el = element;
33+
}
34+
return el;
35+
}
36+
37+
/**
38+
* This patches a username input element to lowercase on input and trim on blur
39+
*
40+
* @param inputElement The input element to patch
41+
*/
42+
function patchUsernameInput(inputElement: HTMLInputElement) {
43+
// Exclude readonly/disabled inputs
44+
if (inputElement.readOnly || inputElement.disabled) return;
45+
46+
const labelElement = inputElement.parentElement?.querySelector("label");
47+
// This is the list of elements which needs to have the data-invalid attribute
48+
// set/unset
49+
const fieldElements: HTMLElement[] = [inputElement];
50+
if (labelElement) fieldElements.push(labelElement);
51+
52+
// Grab the translated 'invalid username' message from the DOM
53+
// TODO: we could expand this to other validation messages, but this is the
54+
// most important one for now
55+
const invalidUsernameMessage = grabErrorMessage(
56+
inputElement.parentElement,
57+
"policy",
58+
"username-invalid-chars",
59+
);
60+
if (!invalidUsernameMessage) {
61+
console.warn(
62+
"Could not find the error message in the DOM for username validation",
63+
inputElement,
64+
);
65+
}
66+
67+
inputElement.addEventListener("input", function () {
68+
// Simply lowercase things automatically, as this is not too disruptive
69+
inputElement.value = inputElement.value.toLocaleLowerCase();
70+
71+
const match = inputElement.value.match(VALID_USERNAME_RE);
72+
if (!inputElement.value.trim() || match !== null) {
73+
// Remove the data-invalid attribute from all elements
74+
for (const el of fieldElements) el.removeAttribute("data-invalid");
75+
76+
// Hide the error message
77+
invalidUsernameMessage?.classList.add("hidden");
78+
} else {
79+
// Set the data-invalid attribute on all elements
80+
for (const el of fieldElements) el.setAttribute("data-invalid", "");
81+
82+
// Show the error message
83+
invalidUsernameMessage?.classList.remove("hidden");
84+
}
85+
});
86+
87+
// Sneakily trim the input on blur
88+
inputElement.addEventListener("blur", function () {
89+
inputElement.value = inputElement.value.trim();
90+
});
91+
}
92+
93+
// Look for username inputs on the page and patch them
94+
for (const element of document.querySelectorAll<HTMLInputElement>(
95+
"input[data-choose-username]",
96+
)) {
97+
patchUsernameInput(element);
98+
}

templates/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<meta name="viewport" content="width=device-width, initial-scale=1">
2626
{{ include_asset('src/entrypoints/shared.css') | indent(4) | safe }}
2727
{{ include_asset('src/entrypoints/templates.css') | indent(4) | safe }}
28+
{{ include_asset('src/entrypoints/templates.ts') | indent(4) | safe }}
2829
{{ captcha.head() }}
2930
</head>
3031
<body>

templates/components/field.html

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,46 @@
1919
{%- if value %} value="{{ value }}" {% endif %}
2020
{%- endmacro %}
2121

22+
{% macro error(error, hidden=false) %}
23+
<div class="cpd-form-message cpd-form-error-message
24+
{%- if hidden %} hidden{% endif %}" data-error-kind="{{ error.kind }}"
25+
{%- if error.code %}data-error-code="{{ error.code }}"{% endif %}>
26+
{% if error.kind == "required" %}
27+
{{ _("mas.errors.field_required") }}
28+
{% elif error.kind == "exists" and field.name == "username" %}
29+
{{ _("mas.errors.username_taken") }}
30+
{% elif error.kind == "policy" %}
31+
{% if error.code == "username-too-short" %}
32+
{{ _("mas.errors.username_too_short") }}
33+
{% elif error.code == "username-too-long" %}
34+
{{ _("mas.errors.username_too_long") }}
35+
{% elif error.code == "username-invalid-chars" %}
36+
{{ _("mas.errors.username_invalid_chars") }}
37+
{% elif error.code == "username-all-numeric" %}
38+
{{ _("mas.errors.username_all_numeric") }}
39+
{% elif error.code == "username-banned" %}
40+
{{ _("mas.errors.username_banned") }}
41+
{% elif error.code == "username-not-allowed" %}
42+
{{ _("mas.errors.username_not_allowed") }}
43+
{% elif error.code == "email-domain-not-allowed" %}
44+
{{ _("mas.errors.email_domain_not_allowed") }}
45+
{% elif error.code == "email-domain-banned" %}
46+
{{ _("mas.errors.email_domain_banned") }}
47+
{% elif error.code == "email-not-allowed" %}
48+
{{ _("mas.errors.email_not_allowed") }}
49+
{% elif error.code == "email-banned" %}
50+
{{ _("mas.errors.email_banned") }}
51+
{% else %}
52+
{{ _("mas.errors.denied_policy", policy=error.message) }}
53+
{% endif %}
54+
{% elif error.kind == "password_mismatch" %}
55+
{{ _("mas.errors.password_mismatch") }}
56+
{% else %}
57+
{{ error.kind }}
58+
{% endif %}
59+
</div>
60+
{% endmacro %}
61+
2262
{% macro field(label, name, form_state=false, class="", inline=false) %}
2363
{% set field_id = new_id() %}
2464
{% if not form_state %}
@@ -55,41 +95,7 @@
5595
{% if field.errors is not empty %}
5696
{% for error in field.errors %}
5797
{% if error.kind != "unspecified" %}
58-
<div class="cpd-form-message cpd-form-error-message">
59-
{% if error.kind == "required" %}
60-
{{ _("mas.errors.field_required") }}
61-
{% elif error.kind == "exists" and field.name == "username" %}
62-
{{ _("mas.errors.username_taken") }}
63-
{% elif error.kind == "policy" %}
64-
{% if error.code == "username-too-short" %}
65-
{{ _("mas.errors.username_too_short") }}
66-
{% elif error.code == "username-too-long" %}
67-
{{ _("mas.errors.username_too_long") }}
68-
{% elif error.code == "username-invalid-chars" %}
69-
{{ _("mas.errors.username_invalid_chars") }}
70-
{% elif error.code == "username-all-numeric" %}
71-
{{ _("mas.errors.username_all_numeric") }}
72-
{% elif error.code == "username-banned" %}
73-
{{ _("mas.errors.username_banned") }}
74-
{% elif error.code == "username-not-allowed" %}
75-
{{ _("mas.errors.username_not_allowed") }}
76-
{% elif error.code == "email-domain-not-allowed" %}
77-
{{ _("mas.errors.email_domain_not_allowed") }}
78-
{% elif error.code == "email-domain-banned" %}
79-
{{ _("mas.errors.email_domain_banned") }}
80-
{% elif error.code == "email-not-allowed" %}
81-
{{ _("mas.errors.email_not_allowed") }}
82-
{% elif error.code == "email-banned" %}
83-
{{ _("mas.errors.email_banned") }}
84-
{% else %}
85-
{{ _("mas.errors.denied_policy", policy=error.message) }}
86-
{% endif %}
87-
{% elif error.kind == "password_mismatch" %}
88-
{{ _("mas.errors.password_mismatch") }}
89-
{% else %}
90-
{{ error.kind }}
91-
{% endif %}
92-
</div>
98+
{{ error(error) }}
9399
{% endif %}
94100
{% endfor %}
95101
{% endif %}

templates/pages/register/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ <h1 class="title">{{ _("mas.register.create_account.heading") }}</h1>
2828

2929
{% if features.password_registration %}
3030
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
31-
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" />
31+
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" data-choose-username />
3232
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
3333
@username:{{ branding.server_name }}
3434
</div>

templates/pages/register/password.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ <h1 class="title">{{ _("mas.register.create_account.heading") }}</h1>
3232
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
3333

3434
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
35-
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required />
35+
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required data-choose-username />
36+
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
3637
{% endcall %}
3738

3839
{% if features.password_registration_email_required %}

templates/pages/upstream_oauth2/do_register.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ <h3 class="provider">
9999
{% endcall %}
100100
{% else %}
101101
{% call(f) field.field(label=_("common.username"), name="username", form_state=form_state) %}
102-
<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" />
102+
<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 />
103+
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
103104

104105
{% if f.errors is empty %}
105106
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">

0 commit comments

Comments
 (0)