diff --git a/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts b/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts index 6550f1f30d..7d7bc3e09e 100644 --- a/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts +++ b/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts @@ -6,24 +6,23 @@ Please see LICENSE files in the repository root for full details. */ import { - MatrixAuthenticationServiceContainer, type MasConfig, type StartedMatrixAuthenticationServiceContainer, type StartedSynapseContainer, - type SynapseConfig, + type SynapseContainer, } 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 { Fixtures } from "@playwright/test"; +import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api"; +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 { test as subBase, expect } from "../../../../playwright/element-web-test"; const MAS_CLIENT_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const MAS_CLIENT_SECRET = "restricted-guests-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_INTERNAL_URL = "http://guest-homeserver:8008"; const MAS_HTTP_LISTENERS: NonNullable["listeners"] = [ { @@ -60,17 +59,11 @@ const MAS_HTTP_LISTENERS: NonNullable["listeners"] = [ }, ]; -const MAS_CONFIG: Partial = { +const BASE_MAS_CONFIG: Partial = { http: { listeners: MAS_HTTP_LISTENERS, public_base: "", }, - matrix: { - kind: "synapse", - homeserver: GUEST_HOMESERVER_NAME, - endpoint: GUEST_HOMESERVER_INTERNAL_URL, - secret: MAS_SHARED_SECRET, - }, policy: { data: { admin_clients: [MAS_CLIENT_ID], @@ -88,17 +81,27 @@ const MAS_CONFIG: Partial = { ], }; -const applySharedTestConfig = (testInstance: typeof base) => { - testInstance.use({ - displayName: "Tommy", - synapseConfig: { - allow_guest_access: true, - }, - labsFlags: ["feature_ask_to_join"], - }); -}; +declare module "@element-hq/element-web-module-api" { + export interface Config { + embedded_pages?: { + login_for_welcome?: boolean; + }; + } +} -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: [ async ({ homeserver, bot }, use) => { 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" }, ], -}; - -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 container.stop(); }, { scope: "worker" }, ], -}); - -const masTest = base.extend< - { - testRoomId: string; - }, - { - guestHomeserver: StartedSynapseContainer; - guestMas: StartedMatrixAuthenticationServiceContainer; - bot: Credentials; - } ->({ - ...sharedFixtures, + // Optional MAS on the module homeserver guestMas: [ - async ({ logger, network, postgres }, use) => { - const container = await new MatrixAuthenticationServiceContainer(postgres) - .withNetwork(network) - .withNetworkAliases("mas") - .withLogConsumer(logger.getConsumer("guest_mas")) - .withConfig(MAS_CONFIG) - .start(); + async ({ logger, network, auth }, use) => { + if (auth !== "mas") { + return use(undefined); + } + // 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 container.stop(); + await postgres.stop(); }, { scope: "worker" }, ], + // Module homeserver guestHomeserver: [ async ({ logger, synapseConfig, network, guestMas }, use) => { - const container = await new RestrictedGuestsSynapseWithMasContainer({ - adminApiBaseUrl: MAS_INTERNAL_URL, - oauthBaseUrl: MAS_INTERNAL_URL, - clientId: MAS_CLIENT_ID, - clientSecret: MAS_CLIENT_SECRET, - }) + let container: SynapseContainer; + if (guestMas) { + container = new RestrictedGuestsSynapseWithMasContainer({ + adminApiBaseUrl: MAS_INTERNAL_URL, + 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({ 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) - .withMatrixAuthenticationService(guestMas) + }) .withNetwork(network) .withNetworkAliases(GUEST_HOMESERVER_NAME) .withLogConsumer(logger.getConsumer("guest_homeserver")) .start(); - await use(container); - await container.stop(); + await use(startedContainer); + await startedContainer.stop(); }, { scope: "worker" }, ], + displayName: "Tommy", + labsFlags: ["feature_ask_to_join"], + config: { + embedded_pages: { + login_for_welcome: true, + }, + }, }); -type RestrictedGuestsTestInstance = typeof test; - -const defineRestrictedGuestsTests = (testInstance: RestrictedGuestsTestInstance, suiteName: string) => { - applySharedTestConfig(testInstance); - - testInstance.describe(suiteName, () => { - testInstance.use({ - page: async ({ page }, use) => { - await page.goto("/"); - await use(page); +base.slow(); +for (const auth of ["mas", "legacy"] as const) { + for (const guestsEnabled of [true, false]) { + const test = base.extend({ + auth, + synapseConfig: { + allow_guest_access: guestsEnabled, }, }); - testInstance("should error if config is missing", async ({ page }) => { - 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, - }; + test.describe(`Restricted guests auth=${auth} guests=${guestsEnabled}`, () => { + test("should error if config is missing", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); + await expect(page.getByText("Errors in module configuration")).toBeVisible(); }); - testInstance( - "should show the default room preview bar for logged in users", - { tag: ["@screenshot"] }, - async ({ page, user, testRoomId }) => { + test.describe("with config", () => { + test.beforeEach(async ({ config, guestHomeserver, page, testRoomId }) => { + config["io.element.element-web-modules.restricted-guests"] = { + guest_user_homeserver_url: guestHomeserver.baseUrl, + }; // 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(); - }, - ); + if (guestsEnabled) { + // 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( - "should show the module's room preview bar for guests", - { tag: ["@screenshot"] }, - async ({ page, testRoomId }) => { - // Go to a room we are not a member of - await page.goto(`/#/room/${testRoomId}`); + test( + "should show the module's room preview bar for guests", + { tag: ["@screenshot"] }, + async ({ page }) => { + const button = page.getByRole("button", { name: "Join as guest", exact: true }); + await expect(button).toBeVisible(); + await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`); - const button = page.getByRole("button", { name: "Join", exact: true }); - await expect(button).toBeVisible(); - await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`); + await button.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toMatchScreenshot(`dialog.png`); - await button.click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toMatchScreenshot(`dialog.png`); + await dialog.getByPlaceholder("Name").fill("Jim"); + await dialog.getByRole("button", { name: "Continue as guest" }).click(); - await dialog.getByPlaceholder("Name").fill("Jim"); - await dialog.getByRole("button", { name: "Continue as guest" }).click(); + await expect(page.getByText("Ask to join?")).toBeVisible(); + }, + ); + } 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)"); + } +} diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png index f053bea1d8..a2b5cb2be8 100644 Binary files a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png differ diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-login-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-login-linux.png new file mode 100644 index 0000000000..aa4f5c37ca Binary files /dev/null and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-login-linux.png differ diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png new file mode 100644 index 0000000000..31d48fab8e Binary files /dev/null and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png differ diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png new file mode 100644 index 0000000000..4e6a663daa Binary files /dev/null and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png differ diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png index 877e466ef5..d08ce9721c 100644 Binary files a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png differ diff --git a/modules/restricted-guests/element-web/src/AuthFooter.tsx b/modules/restricted-guests/element-web/src/AuthFooter.tsx new file mode 100644 index 0000000000..4062c57645 --- /dev/null +++ b/modules/restricted-guests/element-web/src/AuthFooter.tsx @@ -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 = ({ api, config, onLoggedIn }) => { + const onTryJoin = async (): Promise => { + 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 ( + + + + ); +}; + +export default AuthFooter; diff --git a/modules/restricted-guests/element-web/src/RegisterDialog.tsx b/modules/restricted-guests/element-web/src/RegisterDialog.tsx index 1c65708b03..1b445df251 100644 --- a/modules/restricted-guests/element-web/src/RegisterDialog.tsx +++ b/modules/restricted-guests/element-web/src/RegisterDialog.tsx @@ -15,6 +15,7 @@ import { type ModuleConfig } from "./config.ts"; interface RegisterDialogProps extends DialogProps { api: Api; config: ModuleConfig; + showLoginLink?: boolean; } const enum State { @@ -29,7 +30,7 @@ const StyledFormRoot = styled(Form.Root)` font-feature-settings: normal; `; -const RegisterDialog: FC = ({ api, config, onCancel, onSubmit }) => { +const RegisterDialog: FC = ({ api, config, onCancel, onSubmit, showLoginLink }) => { const [username, setUsername] = useState(""); const [state, setState] = useState(State.Idle); @@ -69,7 +70,7 @@ const RegisterDialog: FC = ({ api, config, onCancel, onSubm return ( - + {api.i18n.translate("register_dialog_register_username_label")} = ({ api, config, onCancel, onSubm {message} - - {api.i18n.translate("register_dialog_existing_account")} - + {showLoginLink && ( + + {api.i18n.translate("register_dialog_existing_account")} + + )} {api.i18n.translate("register_dialog_continue_label")} diff --git a/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx b/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx index 75a2b9741e..bda49e57c5 100644 --- a/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx +++ b/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx @@ -45,6 +45,7 @@ const RoomPreviewBar: FC = ({ api, config, roomId, roomAlia { api, config, + showLoginLink: true, }, ); diff --git a/modules/restricted-guests/element-web/src/config.ts b/modules/restricted-guests/element-web/src/config.ts index 07a86902c5..766a6571bb 100644 --- a/modules/restricted-guests/element-web/src/config.ts +++ b/modules/restricted-guests/element-web/src/config.ts @@ -40,5 +40,8 @@ export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests"; declare module "@element-hq/element-web-module-api" { export interface Config { [CONFIG_KEY]: input; + sso_redirect_options?: { + immediate?: boolean; // incompatible option + }; } } diff --git a/modules/restricted-guests/element-web/src/index.tsx b/modules/restricted-guests/element-web/src/index.tsx index a28b5c6c5f..d11e4c0691 100644 --- a/modules/restricted-guests/element-web/src/index.tsx +++ b/modules/restricted-guests/element-web/src/index.tsx @@ -12,6 +12,7 @@ import Translations from "./translations.json"; import { ModuleConfig, CONFIG_KEY } from "./config"; import { name as ModuleName } from "../package.json"; import RoomPreviewBar from "./RoomPreviewBar.tsx"; +import AuthFooter from "./AuthFooter.tsx"; const GUEST_INVISIBLE_COMPONENTS = [ "UIComponent.sendInvites", @@ -41,14 +42,26 @@ class RestrictedGuestsModule implements Module { 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.customisations.registerShouldShowComponent(this.shouldShowComponent); - // TODO replace this with a more generic API - this.api._registerLegacyComponentVisibilityCustomisations(this); + // Login component customisations (for no guest support) + this.api.customComponents.registerLoginComponent((props, OriginalComponent) => ( + + + + )); } /** @@ -58,11 +71,12 @@ class RestrictedGuestsModule implements Module { * @returns true, if the user should see the component */ public readonly shouldShowComponent = (component: string): boolean => { - if (!this.config || !this.api.profile.value.userId?.startsWith(this.config.guest_user_prefix)) { - return true; + const profile = this.api.profile.value; + 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; }; } diff --git a/modules/restricted-guests/element-web/src/translations.json b/modules/restricted-guests/element-web/src/translations.json index bca1dc4a76..315a1e938d 100644 --- a/modules/restricted-guests/element-web/src/translations.json +++ b/modules/restricted-guests/element-web/src/translations.json @@ -4,8 +4,8 @@ "de": "Benutzername" }, "register_dialog_title": { - "en": "Request room access", - "de": "Raumbeitritt anfragen" + "en": "Request access", + "de": "Zugriff anfordern" }, "register_dialog_busy": { "en": "Creating your account...", @@ -32,7 +32,7 @@ "de": "Treten Sie dem Raum bei, um teilzunehmen" }, "join_cta": { - "en": "Join", - "de": "Verbinden" + "en": "Join as guest", + "de": "Als Gast beitreten" } }