Absorb remainder of element-modules into monorepo

This commit is contained in:
Michael Telatynski
2026-06-08 10:32:12 +01:00
112 changed files with 4569 additions and 8 deletions
@@ -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="md" kind="secondary">
{api.i18n.translate("join_cta")}
</Button>
</Container>
);
};
export default AuthFooter;
@@ -0,0 +1,99 @@
/*
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 FC, useState, type JSX, type 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 styled from "styled-components";
import { type ModuleConfig } from "./config.ts";
interface RegisterDialogProps extends DialogProps<AccountAuthInfo> {
api: Api;
config: ModuleConfig;
showLoginLink?: boolean;
}
const enum State {
Idle,
Busy,
Error,
}
const StyledFormRoot = styled(Form.Root)`
font: var(--cpd-font-body-md-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
font-feature-settings: normal;
`;
const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubmit, showLoginLink }) => {
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 (
<StyledFormRoot onSubmit={trySubmit}>
<Form.Field name="name">
<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>
{showLoginLink && (
<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>
</StyledFormRoot>
);
};
export default RegisterDialog;
@@ -0,0 +1,70 @@
/*
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 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 { type ModuleConfig } from "./config.ts";
import RegisterDialog from "./RegisterDialog.tsx";
interface RoomPreviewBarProps {
api: Api;
config: ModuleConfig;
children: JSX.Element;
roomId?: string;
roomAlias?: string;
promptAskToJoin?: boolean;
}
const Container = styled.aside`
margin: auto;
font: var(--cpd-font-body-md-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
font-feature-settings: normal;
`;
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,
showLoginLink: true,
},
);
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;
+47
View File
@@ -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 { z, type ZodMiniType, type input } from "zod/mini";
z.config(z.locales.en());
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.url(),
/**
* The username prefix that identifies guest users.
* @defaultValue `@guest-`
*/
guest_user_prefix: z._default(z.string().check(z.regex(/@[a-zA-Z-_1-9]+/)), "@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._default(z.boolean(), false),
});
export type ModuleConfig = z.infer<typeof ModuleConfig>;
type ConfigSchema = ZodMiniType<z.output<typeof ModuleConfig>, 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]: input<ConfigSchema>;
sso_redirect_options?: {
immediate?: boolean; // incompatible option
};
}
}
+83
View File
@@ -0,0 +1,83 @@
/*
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 compound from "@vector-im/compound-web/dist/style.css" with { type: "css" };
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";
import AuthFooter from "./AuthFooter.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> {
document.adoptedStyleSheets.push(compound);
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}"`);
}
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) => (
<RoomPreviewBar {...props} api={this.api} config={this.config!}>
<OriginalComponent {...props} />
</RoomPreviewBar>
));
this.api.customisations.registerShouldShowComponent(this.shouldShowComponent);
// Login component customisations (for no guest support)
this.api.customComponents.registerLoginComponent((props, OriginalComponent) => (
<OriginalComponent {...props}>
<AuthFooter onLoggedIn={props.onLoggedIn} api={this.api} config={this.config!} />
</OriginalComponent>
));
}
/**
* 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 => {
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 true;
};
}
export default RestrictedGuestsModule satisfies ModuleFactory;
@@ -0,0 +1,38 @@
{
"register_dialog_register_username_label": {
"en": "Username",
"de": "Benutzername"
},
"register_dialog_title": {
"en": "Request access",
"de": "Zugriff anfordern"
},
"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 as guest",
"de": "Als Gast beitreten"
}
}
+8
View File
@@ -0,0 +1,8 @@
/*
Copyright 2025 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.
*/
/// <reference types="@arcmantle/vite-plugin-import-css-sheet/client" />