Element Web module for Restricted Guests
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Restricted Guests Module
|
||||
|
||||
A pair of modules to allow guests to register with Element using the Module API.
|
||||
|
||||
Users get a link to an ask to join room, enter their name, and can participate in the room without any further registration.
|
||||
|
||||
These guest users
|
||||
|
||||
- have a real user account on the Homeserver.
|
||||
- get a username with the (configurable) pattern @guest-<random-identifier>.
|
||||
- have a display name that always includes the (configurable) suffix (Guest).
|
||||
- are restricted in what they can do (can't create rooms or participate in direct messages on the homeserver).
|
||||
- are only temporary and will be deactivated after a lifetime of (configurable) 24 hours.
|
||||
|
||||
This was initially created to allow non-organisation members to join NeoDateFix meeting rooms, even if they don't have a user account in the private and potentially non-federated homeserver.
|
||||
|
||||
See further documentation in the Synapse and Element Web module directories.
|
||||
@@ -0,0 +1,11 @@
|
||||
# @element-hq/element-web-module-restricted-guests
|
||||
|
||||
Restricted Guests module for Element Web.
|
||||
|
||||
Supports the following configuration options under the configuration key `io.element.element-web-modules.restricted-guests`:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| guest_user_homeserver_url | string | URL of the homeserver on which to register the guest, must be running the synapse module. |
|
||||
| guest_user_prefix | string | Prefix to apply to all guests registered via the module, defaults to `@guest-`. |
|
||||
| skip_single_sign_on | boolean | If true, the user will be forwarded to the login page instead of to the SSO login. This is only required if the home server has no SSO support. |
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@element-hq/element-web-module-restricted-guests",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"scripts": {
|
||||
"prepare": "vite build",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:codestyle": "echo 'handled by lint:eslint'",
|
||||
"test": "echo no tests yet"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-web-module-api": "^1.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": "^19",
|
||||
"rollup-plugin-external-globals": "^0.13.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.6",
|
||||
"vite-plugin-node-polyfills": "^0.23.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vector-im/compound-design-tokens": "^4.0.3",
|
||||
"@vector-im/compound-web": "^7.11.0",
|
||||
"styled-components": "^6.1.18",
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { FC, useState, type JSX, FormEvent } from "react";
|
||||
import { type Api, type AccountAuthInfo, type DialogProps } from "@element-hq/element-web-module-api";
|
||||
import { Form } from "@vector-im/compound-web";
|
||||
|
||||
import { ModuleConfig } from "./config.ts";
|
||||
|
||||
export interface RegisterDialogProps extends DialogProps<AccountAuthInfo> {
|
||||
api: Api;
|
||||
config: ModuleConfig;
|
||||
}
|
||||
|
||||
const enum State {
|
||||
Idle,
|
||||
Busy,
|
||||
Error,
|
||||
}
|
||||
|
||||
const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubmit }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [state, setState] = useState<State>(State.Idle);
|
||||
|
||||
async function trySubmit(ev: FormEvent): Promise<void> {
|
||||
ev.preventDefault();
|
||||
setState(State.Busy);
|
||||
|
||||
try {
|
||||
const homeserverUrl = config.guest_user_homeserver_url;
|
||||
|
||||
const url = new URL("/_synapse/client/register_guest", homeserverUrl);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayname: username }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const accountAuthInfo = await response.json();
|
||||
onSubmit(accountAuthInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create guest account", e);
|
||||
setState(State.Error);
|
||||
}
|
||||
}
|
||||
|
||||
let message: JSX.Element | undefined;
|
||||
if (state === State.Error) {
|
||||
message = <Form.ErrorMessage>{api.i18n.translate("register_dialog_error")}</Form.ErrorMessage>;
|
||||
} else if (state === State.Busy) {
|
||||
message = <Form.LoadingMessage>{api.i18n.translate("register_dialog_busy")}</Form.LoadingMessage>;
|
||||
}
|
||||
|
||||
const disabled = state !== State.Idle;
|
||||
|
||||
return (
|
||||
<Form.Root onSubmit={trySubmit}>
|
||||
<Form.Field name="mxid">
|
||||
<Form.Label>{api.i18n.translate("register_dialog_register_username_label")}</Form.Label>
|
||||
<Form.TextControl
|
||||
disabled={disabled}
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.currentTarget.value);
|
||||
}}
|
||||
placeholder={api.i18n.translate("register_dialog_field_label")}
|
||||
/>
|
||||
{message}
|
||||
</Form.Field>
|
||||
|
||||
<a href={config.skip_single_sign_on ? "/#/login" : "/#/start_sso"} onClick={onCancel}>
|
||||
{api.i18n.translate("register_dialog_existing_account")}
|
||||
</a>
|
||||
|
||||
<Form.Submit disabled={disabled || !username}>
|
||||
{api.i18n.translate("register_dialog_continue_label")}
|
||||
</Form.Submit>
|
||||
</Form.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterDialog;
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { FC, type JSX } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
import styled from "styled-components";
|
||||
import { useWatchable } from "@element-hq/element-web-module-api";
|
||||
|
||||
import { ModuleConfig } from "./config.ts";
|
||||
import RegisterDialog from "./RegisterDialog.tsx";
|
||||
|
||||
export interface RoomPreviewBarProps {
|
||||
api: Api;
|
||||
config: ModuleConfig;
|
||||
children: JSX.Element;
|
||||
roomId?: string;
|
||||
roomAlias?: string;
|
||||
promptAskToJoin?: boolean;
|
||||
}
|
||||
|
||||
const Container = styled.aside`
|
||||
margin: auto;
|
||||
`;
|
||||
|
||||
const RoomPreviewBar: FC<RoomPreviewBarProps> = ({ api, config, roomId, roomAlias, promptAskToJoin, children }) => {
|
||||
const profile = useWatchable(api.profile);
|
||||
const isGuest = profile.isGuest;
|
||||
|
||||
if (promptAskToJoin || !isGuest || !(roomId || roomAlias)) return children;
|
||||
|
||||
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) {
|
||||
await api.overwriteAccountAuth(accountAuthInfo);
|
||||
await api.navigation.toMatrixToLink(`https://matrix.to/#/${roomId ?? roomAlias}`, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="mx_RoomPreviewBar">
|
||||
<div className="mx_RoomPreviewBar_message">{api.i18n.translate("join_message")}</div>
|
||||
<div className="mx_RoomPreviewBar_actions">
|
||||
<Button onClick={onTryJoin}>{api.i18n.translate("join_cta")}</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomPreviewBar;
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { z, ZodSchema, ZodTypeDef } from "zod";
|
||||
|
||||
export const ModuleConfig = z.object({
|
||||
/**
|
||||
* The URL of the homeserver where the guest users should be registered. This
|
||||
* must have the `synapse-restricted-guests-module` installed.
|
||||
* @example `https://synapse.local`
|
||||
*/
|
||||
guest_user_homeserver_url: z.string().url(),
|
||||
|
||||
/**
|
||||
* The username prefix that identifies guest users.
|
||||
* @defaultValue `@guest-`
|
||||
*/
|
||||
guest_user_prefix: z
|
||||
.string()
|
||||
.regex(/@[a-zA-Z-_1-9]+/)
|
||||
.default("@guest-"),
|
||||
|
||||
/**
|
||||
* If true, the user will be forwarded to the login page instead of to the SSO
|
||||
* login. This is only required if the home server has no SSO support.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
skip_single_sign_on: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type ModuleConfig = z.infer<typeof ModuleConfig>;
|
||||
|
||||
export type ConfigSchema = ZodSchema<z.output<typeof ModuleConfig>, ZodTypeDef, z.input<typeof ModuleConfig>>;
|
||||
|
||||
export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests";
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
export interface Config {
|
||||
[CONFIG_KEY]: ConfigSchema["_input"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
|
||||
import Translations from "./translations.json";
|
||||
import { ModuleConfig, CONFIG_KEY } from "./config";
|
||||
import { name as ModuleName } from "../package.json";
|
||||
import RoomPreviewBar from "./RoomPreviewBar.tsx";
|
||||
|
||||
const GUEST_INVISIBLE_COMPONENTS = [
|
||||
"UIComponent.sendInvites",
|
||||
"UIComponent.roomCreation",
|
||||
"UIComponent.spaceCreation",
|
||||
"UIComponent.exploreRooms",
|
||||
"UIComponent.roomOptionsMenu",
|
||||
"UIComponent.addIntegrations",
|
||||
];
|
||||
|
||||
class RestrictedGuestsModule implements Module {
|
||||
public static readonly moduleApiVersion = "^1.0.0";
|
||||
|
||||
private config?: ModuleConfig;
|
||||
|
||||
public constructor(private api: Api) {}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
this.api.i18n.register(Translations);
|
||||
|
||||
try {
|
||||
this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY));
|
||||
} catch (e) {
|
||||
console.error("Failed to init module", e);
|
||||
throw new Error(`Errors in module configuration for "${ModuleName}"`);
|
||||
}
|
||||
|
||||
this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => (
|
||||
<RoomPreviewBar {...props} api={this.api} config={this.config!}>
|
||||
<OriginalComponent {...props} />
|
||||
</RoomPreviewBar>
|
||||
));
|
||||
|
||||
// TODO replace this with a more generic API
|
||||
this.api._registerLegacyComponentVisibilityCustomisations(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true, if the `userId` should see the `component`.
|
||||
*
|
||||
* @param component - the name of the component that is checked
|
||||
* @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;
|
||||
}
|
||||
|
||||
return GUEST_INVISIBLE_COMPONENTS.includes(component);
|
||||
};
|
||||
}
|
||||
|
||||
export default RestrictedGuestsModule satisfies ModuleFactory;
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"register_dialog_register_username_label": {
|
||||
"en": "Username",
|
||||
"de": "Benutzername"
|
||||
},
|
||||
"register_dialog_title": {
|
||||
"en": "Request room access",
|
||||
"de": "Raumbeitritt anfragen"
|
||||
},
|
||||
"register_dialog_busy": {
|
||||
"en": "Creating your account...",
|
||||
"de": "Erstelle dein Konto..."
|
||||
},
|
||||
"register_dialog_continue_label": {
|
||||
"en": "Continue as guest",
|
||||
"de": "Als Gast fortfahren"
|
||||
},
|
||||
"register_dialog_field_label": {
|
||||
"en": "Name",
|
||||
"de": "Name"
|
||||
},
|
||||
"register_dialog_existing_account": {
|
||||
"en": "I already have an account.",
|
||||
"de": "Ich habe bereits einen Account."
|
||||
},
|
||||
"register_dialog_error": {
|
||||
"en": "The account creation failed.",
|
||||
"de": "Die Anmeldung als Gastnutzer ist fehlgeschlagen."
|
||||
},
|
||||
"join_message": {
|
||||
"en": "Join the room to participate",
|
||||
"de": "Treten Sie dem Raum bei, um teilzunehmen"
|
||||
},
|
||||
"join_cta": {
|
||||
"en": "Join",
|
||||
"de": "Verbinden"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { StartedSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
|
||||
import { Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api.ts";
|
||||
|
||||
import { test as base, expect } from "../../../../playwright/element-web-test.ts";
|
||||
import { RestrictedGuestsSynapseContainer } from "./services.ts";
|
||||
|
||||
const test = base.extend<
|
||||
{
|
||||
testRoomId: string;
|
||||
},
|
||||
{
|
||||
guestHomeserver: StartedSynapseContainer;
|
||||
bot: Credentials;
|
||||
}
|
||||
>({
|
||||
testRoomId: [
|
||||
async ({ homeserver, bot }, use) => {
|
||||
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
||||
"POST",
|
||||
"/v3/createRoom",
|
||||
bot.accessToken,
|
||||
{
|
||||
name: "Test room",
|
||||
preset: "public_chat",
|
||||
topic: "All about happy hour",
|
||||
initial_state: [
|
||||
{
|
||||
// This is required to allow guests to join the room with this Synapse module
|
||||
type: "m.room.join_rule",
|
||||
state_key: "",
|
||||
content: { join_rule: "knock" },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await use(roomId);
|
||||
},
|
||||
{ scope: "test" },
|
||||
],
|
||||
bot: [
|
||||
async ({ homeserver }, use) => {
|
||||
const bot = await homeserver.registerUser("bot", "pAs5w0rD!", "Bot");
|
||||
await use(bot);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
guestHomeserver: [
|
||||
async ({ logger, synapseConfig, network }, use) => {
|
||||
const container = await new RestrictedGuestsSynapseContainer()
|
||||
.withConfig(synapseConfig)
|
||||
.withConfig({ server_name: "guest-homeserver" })
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases("guest-homeserver")
|
||||
.withLogConsumer(logger.getConsumer("guest_homeserver"))
|
||||
.start();
|
||||
|
||||
await use(container);
|
||||
await container.stop();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
test.use({
|
||||
displayName: "Tommy",
|
||||
synapseConfig: {
|
||||
allow_guest_access: true,
|
||||
},
|
||||
labsFlags: ["feature_ask_to_join"],
|
||||
});
|
||||
|
||||
test.describe("Restricted Guests", () => {
|
||||
test.use({
|
||||
page: async ({ page, homeserver, guestHomeserver }, use) => {
|
||||
await page.goto("/");
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
test("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();
|
||||
});
|
||||
|
||||
test.describe("with config", () => {
|
||||
test.beforeEach(({ config, guestHomeserver }) => {
|
||||
config["io.element.element-web-modules.restricted-guests"] = {
|
||||
guest_user_homeserver_url: guestHomeserver.baseUrl,
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"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}`);
|
||||
|
||||
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 dialog.getByPlaceholder("Name").fill("Jim");
|
||||
await dialog.getByRole("button", { name: "Continue as guest" }).click();
|
||||
|
||||
await expect(page.getByText("Ask to join?")).toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import {
|
||||
StartedSynapseContainer,
|
||||
SynapseContainer,
|
||||
} from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
|
||||
import path, { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// We use the SynapseContainer as a base to have all of its utilities for config setting
|
||||
export class RestrictedGuestsSynapseContainer extends SynapseContainer {
|
||||
public override async start(): Promise<StartedSynapseContainer> {
|
||||
this.withCopyDirectoriesToContainer([
|
||||
{
|
||||
source: path.resolve(__dirname, "..", "..", "synapse", "synapse_guest_module"),
|
||||
target: "/modules/synapse_guest_module/",
|
||||
},
|
||||
]).withEnvironment({
|
||||
PYTHONPATH: "/modules",
|
||||
});
|
||||
this.config.modules.push({
|
||||
module: "synapse_guest_module.GuestModule",
|
||||
config: {},
|
||||
});
|
||||
|
||||
return super.start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
import externalGlobals from "rollup-plugin-external-globals";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.tsx"),
|
||||
name: "element-web-module-restricted-guests",
|
||||
fileName: "index",
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "lib",
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: ["react"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
nodePolyfills({
|
||||
include: ["events"],
|
||||
}),
|
||||
externalGlobals({
|
||||
// Reuse React from the host app
|
||||
react: "window.React",
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
// Use production mode for the build as it is tested against production builds of Element Web,
|
||||
// this is required for React JSX versions to be compatible.
|
||||
process: { env: { NODE_ENV: "production" } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user