Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
30 changes: 20 additions & 10 deletions apps/web/src/components/structures/auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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[];

Expand All @@ -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<IProps, IState> {
class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic!: Login;

Expand All @@ -101,7 +102,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
busy: false,
errorText: null,
loginIncorrect: false,
canTryLogin: true,

username: props.defaultUsername ? props.defaultUsername : "",
phoneCountry: "",
Expand Down Expand Up @@ -229,7 +229,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
username: username,
busy: doWellknownLookup,
errorText: null,
canTryLogin: true,
});
if (doWellknownLookup) {
const serverName = username.split(":").slice(1).join(":");
Expand Down Expand Up @@ -281,7 +280,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
public onRegisterClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
this.props.onRegisterClick?.();
};

public onTryRegisterClick = (ev: ButtonEvent): void => {
Expand Down Expand Up @@ -379,7 +378,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.setState({
errorText: messageForConnectionError(err, this.props.serverConfig),
loginIncorrect: false,
canTryLogin: false,
});
},
)
Expand Down Expand Up @@ -512,7 +510,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
)}
</div>
);
} else if (SettingsStore.getValue(UIFeature.Registration)) {
} else if (this.props.onRegisterClick && SettingsStore.getValue(UIFeature.Registration)) {
footer = (
<span className="mx_AuthBody_changeFlow">
{_t(
Expand Down Expand Up @@ -546,9 +544,21 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
disabled={this.isBusy()}
/>
{this.renderLoginComponentForFlows()}
{this.props.children}
{footer}
</AuthBody>
</AuthPage>
);
}
}

const WrappedLoginComponent = memo((props: IProps): JSX.Element => {
const moduleRenderer = ModuleApi.instance.customComponents.loginComponentRenderer;
if (moduleRenderer) {
return moduleRenderer(props, (props) => <LoginComponent {...props} />);
}

return <LoginComponent {...props} />;
});

export default WrappedLoginComponent;
7 changes: 6 additions & 1 deletion apps/web/src/customisations/helpers/UIComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
2 changes: 2 additions & 0 deletions apps/web/src/modules/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends object>(baseCustomisations: T) => {
let used = false;
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/modules/customComponentApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
}
34 changes: 34 additions & 0 deletions apps/web/src/modules/customisationsApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
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>();

Check warning on line 11 in apps/web/src/modules/customisationsApi.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'shouldShowComponentFunctions' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZzSkO3NH77yBeZelwfo&open=AZzSkO3NH77yBeZelwfo&pullRequest=32687

/**
* 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 next callback, ultimately falling through to `true` if none return false.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth a note that 'next' will depend on module load order and so is a bit of an amorphous concept.

*/
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;
}
}
}
}
42 changes: 1 addition & 41 deletions apps/web/src/settings/UIFeature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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(
<Login
serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
onLoggedIn={() => {}}
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(
<Login
serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
onLoggedIn={() => {}}
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…"));
Expand Down Expand Up @@ -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();
});
});
});
24 changes: 24 additions & 0 deletions apps/web/test/unit-tests/modules/CustomisationsApi-test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading