diff --git a/apps/web/src/components/structures/auth/Login.tsx b/apps/web/src/components/structures/auth/Login.tsx index 006c6c7cb70..582473a028f 100644 --- a/apps/web/src/components/structures/auth/Login.tsx +++ b/apps/web/src/components/structures/auth/Login.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, type ReactNode } from "react"; +import React, { type JSX, memo, type ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix"; @@ -32,6 +32,7 @@ import AccessibleButton, { type ButtonEvent } from "../../views/elements/Accessi import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { filterBoolean } from "../../../utils/arrays"; import { startOidcLogin } from "../../../utils/oidc/authorize"; +import { ModuleApi } from "../../../modules/Api.ts"; interface IProps { serverConfig: ValidatedServerConfig; @@ -45,13 +46,15 @@ interface IProps { defaultDeviceDisplayName?: string; fragmentAfterLogin?: string; defaultUsername?: string; + // Any additional content to show, will be rendered between main actions & footer actions + children?: ReactNode; // Called when the user has logged in. Params: // - The object returned by the login API onLoggedIn(data: IMatrixClientCreds): void; // login shouldn't know or care how registration, password recovery, etc is done. - onRegisterClick(): void; + onRegisterClick?(): void; onForgotPasswordClick?(): void; onServerConfigChange(config: ValidatedServerConfig): void; } @@ -61,8 +64,6 @@ interface IState { busyLoggingIn?: boolean; errorText?: ReactNode; loginIncorrect: boolean; - // can we attempt to log in or are there validation errors? - canTryLogin: boolean; flows?: ClientLoginFlow[]; @@ -88,7 +89,7 @@ type OnPasswordLogin = { /* * A wire component which glues together login UI components and Login logic */ -export default class LoginComponent extends React.PureComponent { +class LoginComponent extends React.PureComponent { private unmounted = false; private loginLogic!: Login; @@ -101,7 +102,6 @@ export default class LoginComponent extends React.PureComponent busy: false, errorText: null, loginIncorrect: false, - canTryLogin: true, username: props.defaultUsername ? props.defaultUsername : "", phoneCountry: "", @@ -229,7 +229,6 @@ export default class LoginComponent extends React.PureComponent username: username, busy: doWellknownLookup, errorText: null, - canTryLogin: true, }); if (doWellknownLookup) { const serverName = username.split(":").slice(1).join(":"); @@ -281,7 +280,7 @@ export default class LoginComponent extends React.PureComponent public onRegisterClick = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); - this.props.onRegisterClick(); + this.props.onRegisterClick?.(); }; public onTryRegisterClick = (ev: ButtonEvent): void => { @@ -379,7 +378,6 @@ export default class LoginComponent extends React.PureComponent this.setState({ errorText: messageForConnectionError(err, this.props.serverConfig), loginIncorrect: false, - canTryLogin: false, }); }, ) @@ -512,7 +510,7 @@ export default class LoginComponent extends React.PureComponent )} ); - } else if (SettingsStore.getValue(UIFeature.Registration)) { + } else if (this.props.onRegisterClick && SettingsStore.getValue(UIFeature.Registration)) { footer = ( {_t( @@ -546,9 +544,21 @@ export default class LoginComponent extends React.PureComponent disabled={this.isBusy()} /> {this.renderLoginComponentForFlows()} + {this.props.children} {footer} ); } } + +const WrappedLoginComponent = memo((props: IProps): JSX.Element => { + const moduleRenderer = ModuleApi.instance.customComponents.loginComponentRenderer; + if (moduleRenderer) { + return moduleRenderer(props, (props) => ); + } + + return ; +}); + +export default WrappedLoginComponent; diff --git a/apps/web/src/customisations/helpers/UIComponents.ts b/apps/web/src/customisations/helpers/UIComponents.ts index 4f15ddaf90e..6e5552706dd 100644 --- a/apps/web/src/customisations/helpers/UIComponents.ts +++ b/apps/web/src/customisations/helpers/UIComponents.ts @@ -8,7 +8,12 @@ Please see LICENSE files in the repository root for full details. import { type UIComponent } from "../../settings/UIFeature"; import { ComponentVisibilityCustomisations } from "../ComponentVisibility"; +import { ModuleApi } from "../../modules/Api.ts"; export function shouldShowComponent(component: UIComponent): boolean { - return ComponentVisibilityCustomisations.shouldShowComponent?.(component) ?? true; + return ( + ModuleApi.instance.customisations.shouldShowComponent(component) ?? + ComponentVisibilityCustomisations.shouldShowComponent?.(component) ?? + true + ); } diff --git a/apps/web/src/modules/Api.ts b/apps/web/src/modules/Api.ts index bcb29f385ed..3a38c3268f5 100644 --- a/apps/web/src/modules/Api.ts +++ b/apps/web/src/modules/Api.ts @@ -31,6 +31,7 @@ import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx"; import { ClientApi } from "./ClientApi.ts"; import { StoresApi } from "./StoresApi.ts"; import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts"; +import { CustomisationsApi } from "./customisationsApi.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -84,6 +85,7 @@ export class ModuleApi implements Api { public readonly config = new ConfigApi(); public readonly i18n = new I18nApi(); public readonly customComponents = new CustomComponentsApi(); + public readonly customisations = new CustomisationsApi(); public readonly extras = new ElementWebExtrasApi(); public readonly builtins = new ElementWebBuiltinsApi(); public readonly widgetLifecycle = new WidgetLifecycleApi(); diff --git a/apps/web/src/modules/customComponentApi.ts b/apps/web/src/modules/customComponentApi.ts index 5121acc7eac..7fd297b94b2 100644 --- a/apps/web/src/modules/customComponentApi.ts +++ b/apps/web/src/modules/customComponentApi.ts @@ -16,6 +16,7 @@ import type { CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints, MatrixEvent as ModuleMatrixEvent, CustomRoomPreviewBarRenderFunction, + CustomLoginRenderFunction, } from "@element-hq/element-web-module-api"; import type React from "react"; @@ -153,4 +154,21 @@ export class CustomComponentsApi implements ICustomComponentsApi { public registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void { this._roomPreviewBarRenderer = renderer; } + + private _loginRenderer?: CustomLoginRenderFunction; + + /** + * Get the custom login component renderer, if any has been registered. + */ + public get loginComponentRenderer(): CustomLoginRenderFunction | undefined { + return this._loginRenderer; + } + + /** + * Register a custom login component renderer. + * @param renderer - the function that will render the login component. + */ + public registerLoginComponent(renderer: CustomLoginRenderFunction): void { + this._loginRenderer = renderer; + } } diff --git a/apps/web/src/modules/customisationsApi.ts b/apps/web/src/modules/customisationsApi.ts new file mode 100644 index 00000000000..25402d510a6 --- /dev/null +++ b/apps/web/src/modules/customisationsApi.ts @@ -0,0 +1,35 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { UIComponent, CustomisationsApi as ICustomisationsApi } from "@element-hq/element-web-module-api"; + +export class CustomisationsApi implements ICustomisationsApi { + private shouldShowComponentFunctions = new Set<(component: UIComponent) => boolean | void>(); + + /** + * Method to register a callback which can affect whether a given component is drawn or not. + * @param fn - the callback, if it returns true the component will be rendered, if false it will not be. + * If undefined will defer to the next callback, ultimately falling through to `true` if none return false. + * The next callback is decided in FIFO call order to this register function. + */ + public registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void { + this.shouldShowComponentFunctions.add(fn); + } + + /** + * Method to check whether, according to any registered modules, a given component should be rendered. + * @param component - the component to check + */ + public shouldShowComponent(component: UIComponent): boolean | void { + for (const fn of this.shouldShowComponentFunctions) { + const v = fn(component); + if (typeof v === "boolean") { + return v; + } + } + } +} diff --git a/apps/web/src/settings/UIFeature.ts b/apps/web/src/settings/UIFeature.ts index 0215b79175f..ddb3d65819e 100644 --- a/apps/web/src/settings/UIFeature.ts +++ b/apps/web/src/settings/UIFeature.ts @@ -28,44 +28,4 @@ export const enum UIFeature { AllowCreatingPublicSpaces = "UIFeature.allowCreatingPublicSpaces", } -export enum UIComponent { - /** - * Components that lead to a user being invited. - */ - InviteUsers = "UIComponent.sendInvites", - - /** - * Components that lead to a room being created that aren't already - * guarded by some other condition (ie: "only if you can edit this - * space" is *not* guarded by this component, but "start DM" is). - */ - CreateRooms = "UIComponent.roomCreation", - - /** - * Components that lead to a Space being created that aren't already - * guarded by some other condition (ie: "only if you can add subspaces" - * is *not* guarded by this component, but "create new space" is). - */ - CreateSpaces = "UIComponent.spaceCreation", - - /** - * Components that lead to the public room directory. - */ - ExploreRooms = "UIComponent.exploreRooms", - - /** - * Components that lead to the user being able to easily add widgets - * and integrations to the room, such as from the room information card. - */ - AddIntegrations = "UIComponent.addIntegrations", - - /** - * Component that lead to the user being able to search, dial, explore rooms - */ - FilterContainer = "UIComponent.filterContainer", - - /** - * Components that lead the user to room options menu. - */ - RoomOptionsMenu = "UIComponent.roomOptionsMenu", -} +export { UIComponent } from "@element-hq/element-web-module-api"; diff --git a/apps/web/test/unit-tests/components/structures/auth/Login-test.tsx b/apps/web/test/unit-tests/components/structures/auth/Login-test.tsx index 431b96a7a7a..9c822e61a2f 100644 --- a/apps/web/test/unit-tests/components/structures/auth/Login-test.tsx +++ b/apps/web/test/unit-tests/components/structures/auth/Login-test.tsx @@ -20,6 +20,7 @@ import Login from "../../../../../src/components/structures/auth/Login"; import type BasePlatform from "../../../../../src/BasePlatform"; import * as registerClientUtils from "../../../../../src/utils/oidc/registerClient"; import { makeDelegatedAuthConfig } from "../../../../test-utils/oidc"; +import { ModuleApi } from "../../../../../src/modules/Api.ts"; jest.useRealTimers(); @@ -100,6 +101,35 @@ describe("Login", function () { expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy(); }); + it("should show register button", async () => { + const onRegisterClick = jest.fn(); + const { getByText } = render( + {}} + onRegisterClick={onRegisterClick} + onServerConfigChange={() => {}} + />, + ); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…")); + + fireEvent.click(getByText("Create an account")); + expect(onRegisterClick).toHaveBeenCalled(); + }); + + it("should hide register button", async () => { + const { queryByText } = render( + {}} + onServerConfigChange={() => {}} + />, + ); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…")); + + expect(queryByText("Create an account")).not.toBeInTheDocument(); + }); + it("should show form without change server link when custom URLs disabled", async () => { const { container } = getComponent(); await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…")); @@ -417,4 +447,16 @@ describe("Login", function () { expect(screen.getByText("Continue")).toBeInTheDocument(); }); }); + + describe("Module API", () => { + afterEach(() => { + ModuleApi.instance.customComponents.registerLoginComponent(undefined as any); + }); + + it("should use registered module renderer", async () => { + ModuleApi.instance.customComponents.registerLoginComponent(() => <>Test component); + const { getByText } = getComponent(); + expect(getByText("Test component")).toBeTruthy(); + }); + }); }); diff --git a/apps/web/test/unit-tests/modules/CustomisationsApi-test.ts b/apps/web/test/unit-tests/modules/CustomisationsApi-test.ts new file mode 100644 index 00000000000..ea4f88e2923 --- /dev/null +++ b/apps/web/test/unit-tests/modules/CustomisationsApi-test.ts @@ -0,0 +1,24 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { CustomisationsApi } from "../../../src/modules/customisationsApi"; +import { UIComponent } from "../../../src/settings/UIFeature.ts"; + +describe("CustomisationsApi", () => { + let api: CustomisationsApi; + + beforeEach(() => { + api = new CustomisationsApi(); + }); + + it("should register a shouldShowComponent callback", () => { + const shouldShowComponent = jest.fn().mockReturnValue(true); + api.registerShouldShowComponent(shouldShowComponent); + expect(api.shouldShowComponent(UIComponent.CreateRooms)).toBe(true); + expect(shouldShowComponent).toHaveBeenCalledWith("UIComponent.roomCreation"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33d8ce5686a..34da86e4a0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@element-hq/element-web-module-api': - specifier: 1.10.0 - version: 1.10.0 + specifier: 1.11.0 + version: 1.11.0 '@element-hq/element-web-playwright-common': specifier: 2.2.7 version: 2.2.7 @@ -149,7 +149,7 @@ importers: version: 7.28.6 '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@element-hq/web-shared-components': specifier: workspace:* version: link:../../packages/shared-components @@ -423,7 +423,7 @@ importers: version: 0.16.3 '@element-hq/element-web-playwright-common': specifier: 'catalog:' - version: 2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2) + version: 2.2.7(@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2) '@element-hq/element-web-playwright-common-local': specifier: workspace:* version: link:../../packages/playwright-common @@ -762,7 +762,7 @@ importers: dependencies: '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@matrix-org/spec': specifier: ^1.7.0 version: 1.16.0 @@ -2016,8 +2016,8 @@ packages: '@element-hq/element-call-embedded@0.16.3': resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==} - '@element-hq/element-web-module-api@1.10.0': - resolution: {integrity: sha512-XIl6E73dn0cmR/03TRCpq7epyFQAa93GUz1j7EBP2pv5Erh59gq788ajFM2XYl7W2afRU0aasvJoI3iZIHbRig==} + '@element-hq/element-web-module-api@1.11.0': + resolution: {integrity: sha512-RBkvt+Z32CGLkiPtYcQTryQBjG01ZrSVV98CS1cPz/kTeBtxEbVBpqDoOLdGvpmVe0dWo4DLaFcldw2iK39TPA==} engines: {node: '>=20.0.0'} peerDependencies: '@matrix-org/react-sdk-module-api': '*' @@ -11980,7 +11980,7 @@ snapshots: '@element-hq/element-call-embedded@0.16.3': {} - '@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': + '@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': dependencies: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -11989,10 +11989,10 @@ snapshots: '@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4) matrix-web-i18n: 3.6.0 - '@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)': + '@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)': dependencies: '@axe-core/playwright': 4.11.1(playwright-core@1.58.2) - '@element-hq/element-web-module-api': 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + '@element-hq/element-web-module-api': 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@playwright/test': 1.58.2 '@testcontainers/postgresql': 11.11.0 glob: 13.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 97e4f5ecb87..1d5548742fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,7 @@ catalog: "@playwright/test": 1.58.2 "playwright-core": 1.58.2 # Module API - "@element-hq/element-web-module-api": 1.10.0 + "@element-hq/element-web-module-api": 1.11.0 # Compound "@vector-im/compound-design-tokens": 6.10.1 "@vector-im/compound-web": 8.4.0