Merge pull request #212 from element-hq/t3chguy/wat/382

This commit is contained in:
Michael Telatynski
2026-04-09 08:56:05 +01:00
committed by GitHub
12 changed files with 254 additions and 151 deletions
@@ -6,24 +6,23 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { import {
MatrixAuthenticationServiceContainer,
type MasConfig, type MasConfig,
type StartedMatrixAuthenticationServiceContainer, type StartedMatrixAuthenticationServiceContainer,
type StartedSynapseContainer, type StartedSynapseContainer,
type SynapseConfig, type SynapseContainer,
} from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
import type { Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api"; import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api";
import type { Fixtures } from "@playwright/test"; import { makePostgres } from "@element-hq/element-web-playwright-common/lib/testcontainers/postgres.js";
import { makeMas } from "@element-hq/element-web-playwright-common/lib/testcontainers/mas.js";
import { test as base, expect } from "../../../../playwright/element-web-test";
import { RestrictedGuestsSynapseContainer, RestrictedGuestsSynapseWithMasContainer } from "./services"; import { RestrictedGuestsSynapseContainer, RestrictedGuestsSynapseWithMasContainer } from "./services";
import { test as subBase, expect } from "../../../../playwright/element-web-test";
const MAS_CLIENT_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const MAS_CLIENT_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
const MAS_CLIENT_SECRET = "restricted-guests-secret"; const MAS_CLIENT_SECRET = "restricted-guests-secret";
const MAS_SHARED_SECRET = "restricted-guests-shared-secret"; const MAS_SHARED_SECRET = "restricted-guests-shared-secret";
const MAS_INTERNAL_URL = "http://mas:8080"; const MAS_INTERNAL_URL = "http://guest-mas:8080";
const GUEST_HOMESERVER_NAME = "guest-homeserver"; const GUEST_HOMESERVER_NAME = "guest-homeserver";
const GUEST_HOMESERVER_INTERNAL_URL = "http://guest-homeserver:8008";
const MAS_HTTP_LISTENERS: NonNullable<MasConfig["http"]>["listeners"] = [ const MAS_HTTP_LISTENERS: NonNullable<MasConfig["http"]>["listeners"] = [
{ {
@@ -60,17 +59,11 @@ const MAS_HTTP_LISTENERS: NonNullable<MasConfig["http"]>["listeners"] = [
}, },
]; ];
const MAS_CONFIG: Partial<MasConfig> = { const BASE_MAS_CONFIG: Partial<MasConfig> = {
http: { http: {
listeners: MAS_HTTP_LISTENERS, listeners: MAS_HTTP_LISTENERS,
public_base: "", public_base: "",
}, },
matrix: {
kind: "synapse",
homeserver: GUEST_HOMESERVER_NAME,
endpoint: GUEST_HOMESERVER_INTERNAL_URL,
secret: MAS_SHARED_SECRET,
},
policy: { policy: {
data: { data: {
admin_clients: [MAS_CLIENT_ID], admin_clients: [MAS_CLIENT_ID],
@@ -88,17 +81,27 @@ const MAS_CONFIG: Partial<MasConfig> = {
], ],
}; };
const applySharedTestConfig = (testInstance: typeof base) => { declare module "@element-hq/element-web-module-api" {
testInstance.use({ export interface Config {
displayName: "Tommy", embedded_pages?: {
synapseConfig: { login_for_welcome?: boolean;
allow_guest_access: true, };
}, }
labsFlags: ["feature_ask_to_join"], }
});
};
const sharedFixtures: Fixtures<{ testRoomId: string }, { bot: Credentials }, any, any> = { // We do some wacky things here in order to run the test suite against multiple homeserver configurations
const base = subBase.extend<
{
testRoomId: string;
},
{
auth: "mas" | "legacy";
bot: Credentials;
guestMas?: StartedMatrixAuthenticationServiceContainer;
guestHomeserver: StartedSynapseContainer;
}
>({
testRoomId: [ testRoomId: [
async ({ homeserver, bot }, use) => { async ({ homeserver, bot }, use) => {
const { room_id: roomId } = (await homeserver.csApi.request("POST", "/v3/createRoom", bot.accessToken, { const { room_id: roomId } = (await homeserver.csApi.request("POST", "/v3/createRoom", bot.accessToken, {
@@ -125,158 +128,178 @@ const sharedFixtures: Fixtures<{ testRoomId: string }, { bot: Credentials }, any
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
};
const test = base.extend<
{
testRoomId: string;
},
{
guestHomeserver: StartedSynapseContainer;
bot: Credentials;
}
>({
...sharedFixtures,
guestHomeserver: [
async ({ logger, synapseConfig, network }, use) => {
const container = await new RestrictedGuestsSynapseContainer()
.withConfig(synapseConfig)
.withConfig({ server_name: GUEST_HOMESERVER_NAME })
.withNetwork(network)
.withNetworkAliases(GUEST_HOMESERVER_NAME)
.withLogConsumer(logger.getConsumer("guest_homeserver"))
.start();
auth: ["mas", { scope: "worker" }],
// Optional MAS on the default homeserver, enabled only when we are testing the non-guest login UX
mas: [
async ({ logger, network, postgres, auth, synapseConfig }, use) => {
if (auth !== "mas" || synapseConfig.allow_guest_access !== false) {
return use(undefined);
}
const container = await makeMas(
postgres,
network,
logger,
{
...BASE_MAS_CONFIG,
matrix: {
kind: "synapse",
homeserver: "homeserver",
endpoint: "http://homeserver:8008",
secret: MAS_SHARED_SECRET,
},
},
"mas",
);
await use(container); await use(container);
await container.stop(); await container.stop();
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
}); // Optional MAS on the module homeserver
const masTest = base.extend<
{
testRoomId: string;
},
{
guestHomeserver: StartedSynapseContainer;
guestMas: StartedMatrixAuthenticationServiceContainer;
bot: Credentials;
}
>({
...sharedFixtures,
guestMas: [ guestMas: [
async ({ logger, network, postgres }, use) => { async ({ logger, network, auth }, use) => {
const container = await new MatrixAuthenticationServiceContainer(postgres) if (auth !== "mas") {
.withNetwork(network) return use(undefined);
.withNetworkAliases("mas") }
.withLogConsumer(logger.getConsumer("guest_mas"))
.withConfig(MAS_CONFIG)
.start();
// We need a separate postgres so it doesn't fight with the default MAS
const postgres = await makePostgres(network, logger, "guest-mas-postgres");
const container = await makeMas(
postgres,
network,
logger,
{
...BASE_MAS_CONFIG,
matrix: {
kind: "synapse",
homeserver: GUEST_HOMESERVER_NAME,
endpoint: "http://guest-homeserver:8008",
secret: MAS_SHARED_SECRET,
},
},
"guest-mas",
);
await use(container); await use(container);
await container.stop(); await container.stop();
await postgres.stop();
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
// Module homeserver
guestHomeserver: [ guestHomeserver: [
async ({ logger, synapseConfig, network, guestMas }, use) => { async ({ logger, synapseConfig, network, guestMas }, use) => {
const container = await new RestrictedGuestsSynapseWithMasContainer({ let container: SynapseContainer;
adminApiBaseUrl: MAS_INTERNAL_URL, if (guestMas) {
oauthBaseUrl: MAS_INTERNAL_URL, container = new RestrictedGuestsSynapseWithMasContainer({
clientId: MAS_CLIENT_ID, adminApiBaseUrl: MAS_INTERNAL_URL,
clientSecret: MAS_CLIENT_SECRET, oauthBaseUrl: MAS_INTERNAL_URL,
}) clientId: MAS_CLIENT_ID,
clientSecret: MAS_CLIENT_SECRET,
}).withMatrixAuthenticationService(guestMas);
} else {
container = new RestrictedGuestsSynapseContainer();
}
const startedContainer = await container
.withConfig(synapseConfig) .withConfig(synapseConfig)
.withConfig({ .withConfig({
server_name: GUEST_HOMESERVER_NAME, server_name: GUEST_HOMESERVER_NAME,
matrix_authentication_service: { })
enabled: true,
endpoint: `${MAS_INTERNAL_URL}/`,
secret: MAS_SHARED_SECRET,
},
// Must be disabled when using MAS.
password_config: {
enabled: false,
},
// Must be disabled when using MAS.
enable_registration: false,
} as Partial<SynapseConfig>)
.withMatrixAuthenticationService(guestMas)
.withNetwork(network) .withNetwork(network)
.withNetworkAliases(GUEST_HOMESERVER_NAME) .withNetworkAliases(GUEST_HOMESERVER_NAME)
.withLogConsumer(logger.getConsumer("guest_homeserver")) .withLogConsumer(logger.getConsumer("guest_homeserver"))
.start(); .start();
await use(container); await use(startedContainer);
await container.stop(); await startedContainer.stop();
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
displayName: "Tommy",
labsFlags: ["feature_ask_to_join"],
config: {
embedded_pages: {
login_for_welcome: true,
},
},
}); });
type RestrictedGuestsTestInstance = typeof test; base.slow();
for (const auth of ["mas", "legacy"] as const) {
const defineRestrictedGuestsTests = (testInstance: RestrictedGuestsTestInstance, suiteName: string) => { for (const guestsEnabled of [true, false]) {
applySharedTestConfig(testInstance); const test = base.extend({
auth,
testInstance.describe(suiteName, () => { synapseConfig: {
testInstance.use({ allow_guest_access: guestsEnabled,
page: async ({ page }, use) => {
await page.goto("/");
await use(page);
}, },
}); });
testInstance("should error if config is missing", async ({ page }) => { test.describe(`Restricted guests auth=${auth} guests=${guestsEnabled}`, () => {
await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); test("should error if config is missing", async ({ page }) => {
await expect(page.getByText("Errors in module configuration")).toBeVisible(); await page.goto("/");
}); await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
await expect(page.getByText("Errors in module configuration")).toBeVisible();
testInstance.describe("with config", () => {
testInstance.beforeEach(({ config, guestHomeserver }) => {
config["io.element.element-web-modules.restricted-guests"] = {
guest_user_homeserver_url: guestHomeserver.baseUrl,
};
}); });
testInstance( test.describe("with config", () => {
"should show the default room preview bar for logged in users", test.beforeEach(async ({ config, guestHomeserver, page, testRoomId }) => {
{ tag: ["@screenshot"] }, config["io.element.element-web-modules.restricted-guests"] = {
async ({ page, user, testRoomId }) => { guest_user_homeserver_url: guestHomeserver.baseUrl,
};
// Go to a room we are not a member of // Go to a room we are not a member of
await page.goto(`/#/room/${testRoomId}`); await page.goto(`/#/room/${testRoomId}`);
});
const button = page.getByRole("button", { name: "Join the discussion" }); if (guestsEnabled) {
await expect(button).toBeVisible(); // The screenshots between the two auth type tests for guests should be identical.
}, test(
); "should show the default room preview bar for logged in users",
{ tag: ["@screenshot"] },
async ({ page, user, testRoomId }) => {
// Go to a room we are not a member of
await page.goto(`/#/room/${testRoomId}`);
const button = page.getByRole("button", { name: "Join the discussion" });
await expect(button).toBeVisible();
},
);
testInstance( test(
"should show the module's room preview bar for guests", "should show the module's room preview bar for guests",
{ tag: ["@screenshot"] }, { tag: ["@screenshot"] },
async ({ page, testRoomId }) => { async ({ page }) => {
// Go to a room we are not a member of const button = page.getByRole("button", { name: "Join as guest", exact: true });
await page.goto(`/#/room/${testRoomId}`); await expect(button).toBeVisible();
await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`);
const button = page.getByRole("button", { name: "Join", exact: true }); await button.click();
await expect(button).toBeVisible(); const dialog = page.getByRole("dialog");
await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`); await expect(dialog).toMatchScreenshot(`dialog.png`);
await button.click(); await dialog.getByPlaceholder("Name").fill("Jim");
const dialog = page.getByRole("dialog"); await dialog.getByRole("button", { name: "Continue as guest" }).click();
await expect(dialog).toMatchScreenshot(`dialog.png`);
await dialog.getByPlaceholder("Name").fill("Jim"); await expect(page.getByText("Ask to join?")).toBeVisible();
await dialog.getByRole("button", { name: "Continue as guest" }).click(); },
);
} else {
test("should show the module login ux", { tag: ["@screenshot"] }, async ({ page }) => {
const button = page.getByRole("button", { name: "Join as guest", exact: true });
await expect(button).toBeVisible();
await expect(page.getByRole("main")).toMatchScreenshot(`login-${auth}.png`);
await expect(page.getByText("Ask to join?")).toBeVisible(); await button.click();
}, const dialog = page.getByRole("dialog");
); await expect(dialog).toMatchScreenshot(`dialog-login.png`);
await dialog.getByPlaceholder("Name").fill("Jim");
await dialog.getByRole("button", { name: "Continue as guest" }).click();
await expect(page.getByText("Join the discussion")).toBeVisible();
});
}
});
}); });
}); }
}; }
// The screenshots between the two tests should be identical.
defineRestrictedGuestsTests(test, "Restricted Guests");
defineRestrictedGuestsTests(masTest as RestrictedGuestsTestInstance, "Restricted Guests (MAS)");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,59 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type FC } from "react";
import { Button } from "@vector-im/compound-web";
import { type AccountAuthInfo, type Api } from "@element-hq/element-web-module-api";
import styled from "styled-components";
import { type ModuleConfig } from "./config.ts";
import RegisterDialog from "./RegisterDialog.tsx";
interface Props {
api: Api;
config: ModuleConfig;
onLoggedIn(data: AccountAuthInfo): void;
}
const Container = styled.aside`
margin: var(--cpd-space-3x) 0;
button {
width: 100%;
}
`;
const AuthFooter: FC<Props> = ({ api, config, onLoggedIn }) => {
const onTryJoin = async (): Promise<void> => {
const { finished } = api.openDialog(
{
title: api.i18n.translate("register_dialog_title"),
},
RegisterDialog,
{
api,
config,
},
);
const { model: accountAuthInfo, ok } = await finished;
if (ok && accountAuthInfo) {
onLoggedIn(accountAuthInfo);
}
};
return (
<Container>
<Button onClick={onTryJoin} size="sm" kind="secondary">
{api.i18n.translate("join_cta")}
</Button>
</Container>
);
};
export default AuthFooter;
@@ -15,6 +15,7 @@ import { type ModuleConfig } from "./config.ts";
interface RegisterDialogProps extends DialogProps<AccountAuthInfo> { interface RegisterDialogProps extends DialogProps<AccountAuthInfo> {
api: Api; api: Api;
config: ModuleConfig; config: ModuleConfig;
showLoginLink?: boolean;
} }
const enum State { const enum State {
@@ -29,7 +30,7 @@ const StyledFormRoot = styled(Form.Root)`
font-feature-settings: normal; font-feature-settings: normal;
`; `;
const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubmit }) => { const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubmit, showLoginLink }) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [state, setState] = useState<State>(State.Idle); const [state, setState] = useState<State>(State.Idle);
@@ -69,7 +70,7 @@ const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubm
return ( return (
<StyledFormRoot onSubmit={trySubmit}> <StyledFormRoot onSubmit={trySubmit}>
<Form.Field name="mxid"> <Form.Field name="name">
<Form.Label>{api.i18n.translate("register_dialog_register_username_label")}</Form.Label> <Form.Label>{api.i18n.translate("register_dialog_register_username_label")}</Form.Label>
<Form.TextControl <Form.TextControl
disabled={disabled} disabled={disabled}
@@ -82,9 +83,11 @@ const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubm
{message} {message}
</Form.Field> </Form.Field>
<a href={config.skip_single_sign_on ? "/#/login" : "/#/start_sso"} onClick={onCancel}> {showLoginLink && (
{api.i18n.translate("register_dialog_existing_account")} <a href={config.skip_single_sign_on ? "/#/login" : "/#/start_sso"} onClick={onCancel}>
</a> {api.i18n.translate("register_dialog_existing_account")}
</a>
)}
<Form.Submit disabled={disabled || !username}> <Form.Submit disabled={disabled || !username}>
{api.i18n.translate("register_dialog_continue_label")} {api.i18n.translate("register_dialog_continue_label")}
@@ -45,6 +45,7 @@ const RoomPreviewBar: FC<RoomPreviewBarProps> = ({ api, config, roomId, roomAlia
{ {
api, api,
config, config,
showLoginLink: true,
}, },
); );
@@ -40,5 +40,8 @@ export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests";
declare module "@element-hq/element-web-module-api" { declare module "@element-hq/element-web-module-api" {
export interface Config { export interface Config {
[CONFIG_KEY]: input<ConfigSchema>; [CONFIG_KEY]: input<ConfigSchema>;
sso_redirect_options?: {
immediate?: boolean; // incompatible option
};
} }
} }
@@ -12,6 +12,7 @@ import Translations from "./translations.json";
import { ModuleConfig, CONFIG_KEY } from "./config"; import { ModuleConfig, CONFIG_KEY } from "./config";
import { name as ModuleName } from "../package.json"; import { name as ModuleName } from "../package.json";
import RoomPreviewBar from "./RoomPreviewBar.tsx"; import RoomPreviewBar from "./RoomPreviewBar.tsx";
import AuthFooter from "./AuthFooter.tsx";
const GUEST_INVISIBLE_COMPONENTS = [ const GUEST_INVISIBLE_COMPONENTS = [
"UIComponent.sendInvites", "UIComponent.sendInvites",
@@ -41,14 +42,26 @@ class RestrictedGuestsModule implements Module {
throw new Error(`Errors in module configuration for "${ModuleName}"`); throw new Error(`Errors in module configuration for "${ModuleName}"`);
} }
const appConfig = this.api.config.get();
if (appConfig.sso_redirect_options?.immediate) {
console.warn(`${ModuleName} found incompatible option 'sso_redirect_options.immediate', turning it off.`);
appConfig.sso_redirect_options.immediate = false;
}
// Room preview bar customisations (for Matrix guest support)
this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => ( this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => (
<RoomPreviewBar {...props} api={this.api} config={this.config!}> <RoomPreviewBar {...props} api={this.api} config={this.config!}>
<OriginalComponent {...props} /> <OriginalComponent {...props} />
</RoomPreviewBar> </RoomPreviewBar>
)); ));
this.api.customisations.registerShouldShowComponent(this.shouldShowComponent);
// TODO replace this with a more generic API // Login component customisations (for no guest support)
this.api._registerLegacyComponentVisibilityCustomisations(this); this.api.customComponents.registerLoginComponent((props, OriginalComponent) => (
<OriginalComponent {...props}>
<AuthFooter onLoggedIn={props.onLoggedIn} api={this.api} config={this.config!} />
</OriginalComponent>
));
} }
/** /**
@@ -58,11 +71,12 @@ class RestrictedGuestsModule implements Module {
* @returns true, if the user should see the component * @returns true, if the user should see the component
*/ */
public readonly shouldShowComponent = (component: string): boolean => { public readonly shouldShowComponent = (component: string): boolean => {
if (!this.config || !this.api.profile.value.userId?.startsWith(this.config.guest_user_prefix)) { const profile = this.api.profile.value;
return true; if (this.config && (profile.isGuest || profile.userId?.startsWith(this.config.guest_user_prefix))) {
return GUEST_INVISIBLE_COMPONENTS.includes(component);
} }
return GUEST_INVISIBLE_COMPONENTS.includes(component); return true;
}; };
} }
@@ -4,8 +4,8 @@
"de": "Benutzername" "de": "Benutzername"
}, },
"register_dialog_title": { "register_dialog_title": {
"en": "Request room access", "en": "Request access",
"de": "Raumbeitritt anfragen" "de": "Zugriff anfordern"
}, },
"register_dialog_busy": { "register_dialog_busy": {
"en": "Creating your account...", "en": "Creating your account...",
@@ -32,7 +32,7 @@
"de": "Treten Sie dem Raum bei, um teilzunehmen" "de": "Treten Sie dem Raum bei, um teilzunehmen"
}, },
"join_cta": { "join_cta": {
"en": "Join", "en": "Join as guest",
"de": "Verbinden" "de": "Als Gast beitreten"
} }
} }