Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@

<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/entrypoints/main.tsx"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion frontend/knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { KnipConfig } from "knip";

export default {
entry: ["src/main.tsx", "src/swagger.ts", "src/routes/*"],
entry: ["src/entrypoints/*", "src/routes/*"],
ignore: [
"src/gql/*",
"src/routeTree.gen.ts",
Expand Down
1 change: 1 addition & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"rimraf": "^6.0.1",
"storybook": "^10.0.5",
"tailwindcss": "^3.4.18",
"tinyglobby": "^0.2.15",
"typescript": "^5.9.3",
"vite": "7.2.2",
"vite-plugin-compression": "^0.5.1",
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/main.tsx → frontend/src/entrypoints/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import { TooltipProvider } from "@vector-im/compound-web";
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import ErrorBoundary from "./components/ErrorBoundary";
import LoadingScreen from "./components/LoadingScreen";
import { queryClient } from "./graphql";
import i18n, { setupI18n } from "./i18n";
import { router } from "./router";
import ErrorBoundary from "../components/ErrorBoundary";
import LoadingScreen from "../components/LoadingScreen";
import { queryClient } from "../graphql";
import i18n, { setupI18n } from "../i18n";
import { router } from "../router";
import "./shared.css";

setupI18n();
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
* Please see LICENSE files in the repository root for full details.
*/

@import url("./styles/cpd-button.css");
@import url("./styles/cpd-form.css");
@import url("./styles/cpd-link.css");
@import url("./styles/cpd-text-control.css");
@import url("./styles/cpd-mfa-control.css");
@import url("./styles/cpd-checkbox-control.css");

@import url("./components/SessionCard/SessionCard.module.css");
@import url("./components/Session/DeviceTypeIcon.module.css");
@import url("./components/Layout/Layout.module.css");
@import url("./components/Footer/Footer.module.css");
@import url("./components/PageHeading/PageHeading.module.css");
@import url("../styles/cpd-button.css");
@import url("../styles/cpd-form.css");
@import url("../styles/cpd-link.css");
@import url("../styles/cpd-text-control.css");
@import url("../styles/cpd-mfa-control.css");
@import url("../styles/cpd-checkbox-control.css");

@import url("../components/SessionCard/SessionCard.module.css");
@import url("../components/Session/DeviceTypeIcon.module.css");
@import url("../components/Layout/Layout.module.css");
@import url("../components/Footer/Footer.module.css");
@import url("../components/PageHeading/PageHeading.module.css");

.cpd-text-body-lg-regular {
font: var(--cpd-font-body-lg-regular);
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/entrypoints/templates.ts
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 3 additions & 6 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";

import { globSync } from "tinyglobby";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import browserslistToEsbuild from "browserslist-to-esbuild";
Expand Down Expand Up @@ -53,12 +54,8 @@ export default defineConfig((env) => ({
cssCodeSplit: true,

rollupOptions: {
input: [
resolve(__dirname, "src/main.tsx"),
resolve(__dirname, "src/shared.css"),
resolve(__dirname, "src/templates.css"),
resolve(__dirname, "src/swagger.ts"),
],
// This uses all the files in the src/entrypoints directory as inputs
input: globSync(resolve(__dirname, "src/entrypoints/**/*.{css,ts,tsx}")),
},
},

Expand Down
2 changes: 1 addition & 1 deletion templates/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<script>
window.APP_CONFIG = JSON.parse("{{ config | tojson | add_slashes | safe }}");
</script>
{{ include_asset('src/main.tsx') | indent(4) | safe }}
{{ include_asset('src/entrypoints/main.tsx') | indent(4) | safe }}
</head>

<body>
Expand Down
5 changes: 3 additions & 2 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
<meta charset="utf-8">
<title>{% block title %}{{ _("app.name") }}{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ include_asset('src/shared.css') | indent(4) | safe }}
{{ include_asset('src/templates.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.ts') | indent(4) | safe }}
{{ captcha.head() }}
</head>
<body>
Expand Down
76 changes: 41 additions & 35 deletions templates/components/field.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,46 @@
{%- 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 %}
Expand Down Expand Up @@ -55,41 +95,7 @@
{% 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 %}
Expand Down
2 changes: 1 addition & 1 deletion templates/pages/register/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ <h1 class="title">{{ _("mas.register.create_account.heading") }}</h1>

{% 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>
Expand Down
3 changes: 2 additions & 1 deletion templates/pages/register/password.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ <h1 class="title">{{ _("mas.register.create_account.heading") }}</h1>
<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 %}
Expand Down
3 changes: 2 additions & 1 deletion templates/pages/upstream_oauth2/do_register.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ <h3 class="provider">
{% 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">
Expand Down
2 changes: 1 addition & 1 deletion templates/swagger/doc.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
callbackUrl: "{{ callback_url | add_slashes | safe }}",
};
</script>
{{ include_asset('src/swagger.ts') | indent(4) | safe }}
{{ include_asset('src/entrypoints/swagger.ts') | indent(4) | safe }}
</head>

<body>
Expand Down
Loading