Skip to content

Commit 480c2e3

Browse files
authored
Merge pull request #50 from element-hq/t3chguy/guest-module
2 parents 9e90964 + 94643aa commit 480c2e3

File tree

15 files changed

+586
-1
lines changed

15 files changed

+586
-1
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Restricted Guests Module
2+
3+
A pair of modules to allow guests to register with Element using the Module API.
4+
5+
Users get a link to an ask to join room, enter their name, and can participate in the room without any further registration.
6+
7+
These guest users
8+
9+
- have a real user account on the Homeserver.
10+
- get a username with the (configurable) pattern @guest-<random-identifier>.
11+
- have a display name that always includes the (configurable) suffix (Guest).
12+
- are restricted in what they can do (can't create rooms or participate in direct messages on the homeserver).
13+
- are only temporary and will be deactivated after a lifetime of (configurable) 24 hours.
14+
15+
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.
16+
17+
See further documentation in the Synapse and Element Web module directories.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# @element-hq/element-web-module-restricted-guests
2+
3+
Restricted Guests module for Element Web.
4+
5+
Supports the following configuration options under the configuration key `io.element.element-web-modules.restricted-guests`:
6+
7+
| Key | Type | Description |
8+
| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
9+
| guest_user_homeserver_url | string | URL of the homeserver on which to register the guest, must be running the synapse module. |
10+
| guest_user_prefix | string | Prefix to apply to all guests registered via the module, defaults to `@guest-`. |
11+
| 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. |
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "@element-hq/element-web-module-restricted-guests",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"main": "lib/index.js",
7+
"scripts": {
8+
"prepare": "vite build",
9+
"lint:types": "tsc --noEmit",
10+
"lint:codestyle": "echo 'handled by lint:eslint'",
11+
"test": "echo no tests yet"
12+
},
13+
"devDependencies": {
14+
"@element-hq/element-web-module-api": "^1.0.0",
15+
"@types/node": "^22.10.7",
16+
"@types/react": "^19",
17+
"@vitejs/plugin-react": "^4.3.4",
18+
"react": "^19",
19+
"rollup-plugin-external-globals": "^0.13.0",
20+
"typescript": "^5.7.3",
21+
"vite": "^6.1.6",
22+
"vite-plugin-node-polyfills": "^0.23.0"
23+
},
24+
"dependencies": {
25+
"@vector-im/compound-design-tokens": "^4.0.3",
26+
"@vector-im/compound-web": "^7.11.0",
27+
"styled-components": "^6.1.18",
28+
"zod": "^3.24.2"
29+
}
30+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { FC, useState, type JSX, FormEvent } from "react";
9+
import { type Api, type AccountAuthInfo, type DialogProps } from "@element-hq/element-web-module-api";
10+
import { Form } from "@vector-im/compound-web";
11+
12+
import { ModuleConfig } from "./config.ts";
13+
14+
interface RegisterDialogProps extends DialogProps<AccountAuthInfo> {
15+
api: Api;
16+
config: ModuleConfig;
17+
}
18+
19+
const enum State {
20+
Idle,
21+
Busy,
22+
Error,
23+
}
24+
25+
const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubmit }) => {
26+
const [username, setUsername] = useState("");
27+
const [state, setState] = useState<State>(State.Idle);
28+
29+
async function trySubmit(ev: FormEvent): Promise<void> {
30+
ev.preventDefault();
31+
setState(State.Busy);
32+
33+
try {
34+
const homeserverUrl = config.guest_user_homeserver_url;
35+
36+
const url = new URL("/_synapse/client/register_guest", homeserverUrl);
37+
38+
const response = await fetch(url, {
39+
method: "POST",
40+
headers: { "Content-Type": "application/json" },
41+
body: JSON.stringify({ displayname: username }),
42+
});
43+
44+
if (response.ok) {
45+
const accountAuthInfo = await response.json();
46+
onSubmit(accountAuthInfo);
47+
}
48+
} catch (e) {
49+
console.error("Failed to create guest account", e);
50+
setState(State.Error);
51+
}
52+
}
53+
54+
let message: JSX.Element | undefined;
55+
if (state === State.Error) {
56+
message = <Form.ErrorMessage>{api.i18n.translate("register_dialog_error")}</Form.ErrorMessage>;
57+
} else if (state === State.Busy) {
58+
message = <Form.LoadingMessage>{api.i18n.translate("register_dialog_busy")}</Form.LoadingMessage>;
59+
}
60+
61+
const disabled = state !== State.Idle;
62+
63+
return (
64+
<Form.Root onSubmit={trySubmit}>
65+
<Form.Field name="mxid">
66+
<Form.Label>{api.i18n.translate("register_dialog_register_username_label")}</Form.Label>
67+
<Form.TextControl
68+
disabled={disabled}
69+
value={username}
70+
onChange={(event) => {
71+
setUsername(event.currentTarget.value);
72+
}}
73+
placeholder={api.i18n.translate("register_dialog_field_label")}
74+
/>
75+
{message}
76+
</Form.Field>
77+
78+
<a href={config.skip_single_sign_on ? "/#/login" : "/#/start_sso"} onClick={onCancel}>
79+
{api.i18n.translate("register_dialog_existing_account")}
80+
</a>
81+
82+
<Form.Submit disabled={disabled || !username}>
83+
{api.i18n.translate("register_dialog_continue_label")}
84+
</Form.Submit>
85+
</Form.Root>
86+
);
87+
};
88+
89+
export default RegisterDialog;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { FC, type JSX } from "react";
9+
import { Button } from "@vector-im/compound-web";
10+
import { type Api } from "@element-hq/element-web-module-api";
11+
import styled from "styled-components";
12+
import { useWatchable } from "@element-hq/element-web-module-api";
13+
14+
import { ModuleConfig } from "./config.ts";
15+
import RegisterDialog from "./RegisterDialog.tsx";
16+
17+
interface RoomPreviewBarProps {
18+
api: Api;
19+
config: ModuleConfig;
20+
children: JSX.Element;
21+
roomId?: string;
22+
roomAlias?: string;
23+
promptAskToJoin?: boolean;
24+
}
25+
26+
const Container = styled.aside`
27+
margin: auto;
28+
`;
29+
30+
const RoomPreviewBar: FC<RoomPreviewBarProps> = ({ api, config, roomId, roomAlias, promptAskToJoin, children }) => {
31+
const profile = useWatchable(api.profile);
32+
const isGuest = profile.isGuest;
33+
34+
if (promptAskToJoin || !isGuest || !(roomId || roomAlias)) return children;
35+
36+
const onTryJoin = async (): Promise<void> => {
37+
const { finished } = api.openDialog(
38+
{
39+
title: api.i18n.translate("register_dialog_title"),
40+
},
41+
RegisterDialog,
42+
{
43+
api,
44+
config,
45+
},
46+
);
47+
48+
const { model: accountAuthInfo, ok } = await finished;
49+
50+
if (ok && accountAuthInfo) {
51+
await api.overwriteAccountAuth(accountAuthInfo);
52+
await api.navigation.toMatrixToLink(`https://matrix.to/#/${roomId ?? roomAlias}`, true);
53+
}
54+
};
55+
56+
return (
57+
<Container className="mx_RoomPreviewBar">
58+
<div className="mx_RoomPreviewBar_message">{api.i18n.translate("join_message")}</div>
59+
<div className="mx_RoomPreviewBar_actions">
60+
<Button onClick={onTryJoin}>{api.i18n.translate("join_cta")}</Button>
61+
</div>
62+
</Container>
63+
);
64+
};
65+
66+
export default RoomPreviewBar;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { z, ZodSchema, ZodTypeDef } from "zod";
9+
10+
export const ModuleConfig = z.object({
11+
/**
12+
* The URL of the homeserver where the guest users should be registered. This
13+
* must have the `synapse-restricted-guests-module` installed.
14+
* @example `https://synapse.local`
15+
*/
16+
guest_user_homeserver_url: z.string().url(),
17+
18+
/**
19+
* The username prefix that identifies guest users.
20+
* @defaultValue `@guest-`
21+
*/
22+
guest_user_prefix: z
23+
.string()
24+
.regex(/@[a-zA-Z-_1-9]+/)
25+
.default("@guest-"),
26+
27+
/**
28+
* If true, the user will be forwarded to the login page instead of to the SSO
29+
* login. This is only required if the home server has no SSO support.
30+
* @defaultValue `false`
31+
*/
32+
skip_single_sign_on: z.boolean().default(false),
33+
});
34+
35+
export type ModuleConfig = z.infer<typeof ModuleConfig>;
36+
37+
type ConfigSchema = ZodSchema<z.output<typeof ModuleConfig>, ZodTypeDef, z.input<typeof ModuleConfig>>;
38+
39+
export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests";
40+
41+
declare module "@element-hq/element-web-module-api" {
42+
export interface Config {
43+
[CONFIG_KEY]: ConfigSchema["_input"];
44+
}
45+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
9+
import Translations from "./translations.json";
10+
import { ModuleConfig, CONFIG_KEY } from "./config";
11+
import { name as ModuleName } from "../package.json";
12+
import RoomPreviewBar from "./RoomPreviewBar.tsx";
13+
14+
const GUEST_INVISIBLE_COMPONENTS = [
15+
"UIComponent.sendInvites",
16+
"UIComponent.roomCreation",
17+
"UIComponent.spaceCreation",
18+
"UIComponent.exploreRooms",
19+
"UIComponent.roomOptionsMenu",
20+
"UIComponent.addIntegrations",
21+
];
22+
23+
class RestrictedGuestsModule implements Module {
24+
public static readonly moduleApiVersion = "^1.0.0";
25+
26+
private config?: ModuleConfig;
27+
28+
public constructor(private api: Api) {}
29+
30+
public async load(): Promise<void> {
31+
this.api.i18n.register(Translations);
32+
33+
try {
34+
this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY));
35+
} catch (e) {
36+
console.error("Failed to init module", e);
37+
throw new Error(`Errors in module configuration for "${ModuleName}"`);
38+
}
39+
40+
this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => (
41+
<RoomPreviewBar {...props} api={this.api} config={this.config!}>
42+
<OriginalComponent {...props} />
43+
</RoomPreviewBar>
44+
));
45+
46+
// TODO replace this with a more generic API
47+
this.api._registerLegacyComponentVisibilityCustomisations(this);
48+
}
49+
50+
/**
51+
* Returns true, if the `userId` should see the `component`.
52+
*
53+
* @param component - the name of the component that is checked
54+
* @returns true, if the user should see the component
55+
*/
56+
public readonly shouldShowComponent = (component: string): boolean => {
57+
if (!this.config || !this.api.profile.value.userId?.startsWith(this.config.guest_user_prefix)) {
58+
return true;
59+
}
60+
61+
return GUEST_INVISIBLE_COMPONENTS.includes(component);
62+
};
63+
}
64+
65+
export default RestrictedGuestsModule satisfies ModuleFactory;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"register_dialog_register_username_label": {
3+
"en": "Username",
4+
"de": "Benutzername"
5+
},
6+
"register_dialog_title": {
7+
"en": "Request room access",
8+
"de": "Raumbeitritt anfragen"
9+
},
10+
"register_dialog_busy": {
11+
"en": "Creating your account...",
12+
"de": "Erstelle dein Konto..."
13+
},
14+
"register_dialog_continue_label": {
15+
"en": "Continue as guest",
16+
"de": "Als Gast fortfahren"
17+
},
18+
"register_dialog_field_label": {
19+
"en": "Name",
20+
"de": "Name"
21+
},
22+
"register_dialog_existing_account": {
23+
"en": "I already have an account.",
24+
"de": "Ich habe bereits einen Account."
25+
},
26+
"register_dialog_error": {
27+
"en": "The account creation failed.",
28+
"de": "Die Anmeldung als Gastnutzer ist fehlgeschlagen."
29+
},
30+
"join_message": {
31+
"en": "Join the room to participate",
32+
"de": "Treten Sie dem Raum bei, um teilzunehmen"
33+
},
34+
"join_cta": {
35+
"en": "Join",
36+
"de": "Verbinden"
37+
}
38+
}

0 commit comments

Comments
 (0)