Add support for GitLab connection configuration via widgets (#320)

* Refactors to support multiple services in the widget UI

* Add GitLabRepo web component view

* Support searching and adding a GitLab repository on the frontend

* Add final bits to supporting adding and removing connections from rooms

* lint

* changelog

* Lots of changes based upon review feedback

* linting

* Add warnings

* Withdraw openid overrides change for this PR

* Drop unused

* Remove async function syntax

* Tiny bug fixes
This commit is contained in:
Will Hunt 2022-04-30 06:08:13 +01:00 committed by GitHub
parent f56545bf7c
commit 8808385b6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 678 additions and 206 deletions

1
changelog.d/320.feature Normal file
View File

@ -0,0 +1 @@
Add support for GitLab in the widgets configuration UI.

View File

@ -8,6 +8,7 @@ import { GitHubRepoConnectionOptions } from "../Connections/GithubRepo";
import { BridgeConfigActorPermission, BridgePermissions } from "../libRs";
import LogWrapper from "../LogWrapper";
import { ConfigError } from "../errors";
import { ApiError, ErrCode } from "../api";
const log = new LogWrapper("Config");
@ -185,6 +186,14 @@ export class BridgeConfigGitLab {
this.userIdPrefix = yaml.userIdPrefix || "_gitlab_";
}
@hideKey()
public get publicConfig() {
return {
userIdPrefix: this.userIdPrefix,
}
}
public getInstanceByProjectUrl(url: string): {name: string, instance: GitLabInstance}|null {
for (const [name, instance] of Object.entries(this.instances)) {
if (url.startsWith(instance.url)) {
@ -537,6 +546,25 @@ export class BridgeConfig {
return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]);
}
public getPublicConfigForService(serviceName: string): Record<string, unknown> {
let config: undefined|Record<string, unknown>;
switch (serviceName) {
case "generic":
config = this.generic?.publicConfig;
break;
case "gitlab":
config = this.gitlab?.publicConfig;
break;
default:
throw new ApiError("Not a known service, or service doesn't expose a config", ErrCode.NotFound);
}
if (!config) {
throw new ApiError("Service is not enabled", ErrCode.DisabledFeature);
}
return config;
}
static async parseConfig(filename: string, env: {[key: string]: string|undefined}) {
const file = await fs.readFile(filename, "utf-8");
return new BridgeConfig(YAML.parse(file), env);

View File

@ -75,13 +75,13 @@ export class ConnectionManager extends EventEmitter {
log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with ${data}`);
const existingConnections = await this.getAllConnectionsForRoom(roomId);
if (JiraProjectConnection.EventTypes.includes(type)) {
if (!this.config.jira) {
throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature);
}
if (existingConnections.find(c => c instanceof JiraProjectConnection)) {
// TODO: Support this.
throw Error("Cannot support multiple connections of the same type yet");
}
if (!this.config.jira) {
throw Error('JIRA is not configured');
}
if (!this.config.checkPermission(userId, "jira", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for Jira', ErrCode.ForbiddenUser);
}
@ -91,13 +91,13 @@ export class ConnectionManager extends EventEmitter {
return res.connection;
}
if (GitHubRepoConnection.EventTypes.includes(type)) {
if (!this.config.github || !this.config.github.oauth || !this.github) {
throw new ApiError('GitHub integration is not configured', ErrCode.DisabledFeature);
}
if (existingConnections.find(c => c instanceof GitHubRepoConnection)) {
// TODO: Support this.
throw Error("Cannot support multiple connections of the same type yet");
}
if (!this.config.github || !this.config.github.oauth || !this.github) {
throw Error('GitHub is not configured');
}
if (!this.config.checkPermission(userId, "github", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for GitHub', ErrCode.ForbiddenUser);
}
@ -108,7 +108,7 @@ export class ConnectionManager extends EventEmitter {
}
if (GenericHookConnection.EventTypes.includes(type)) {
if (!this.config.generic) {
throw Error('Generic hook support not supported');
throw new ApiError('Generic Webhook integration is not configured', ErrCode.DisabledFeature);
}
if (!this.config.checkPermission(userId, "webhooks", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for generic webhooks', ErrCode.ForbiddenUser);
@ -125,6 +125,24 @@ export class ConnectionManager extends EventEmitter {
this.push(res.connection);
return res.connection;
}
if (GitLabRepoConnection.EventTypes.includes(type)) {
if (!this.config.gitlab) {
throw new ApiError('GitLab integration is not configured', ErrCode.DisabledFeature);
}
if (!this.config.checkPermission(userId, "gitlab", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for GitLab', ErrCode.ForbiddenUser);
}
const res = await GitLabRepoConnection.provisionConnection(roomId, userId, data, this.as, this.tokenStore, data.instance as string, this.config.gitlab);
const existing = this.getAllConnectionsOfType(GitLabRepoConnection).find(c => c.stateKey === res.connection.stateKey);
if (existing) {
throw new ApiError("A GitLab repo connection for this project already exists", ErrCode.ConflictingConnection, -1, {
existingConnection: existing.getProvisionerDetails()
});
}
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, GitLabRepoConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent);
this.push(res.connection);
return res.connection;
}
throw new ApiError(`Connection type not known`);
}
@ -447,4 +465,23 @@ export class ConnectionManager extends EventEmitter {
}
this.enabledForProvisioning[details.type] = details;
}
/**
* Get a list of possible targets for a given connection type when provisioning
* @param userId
* @param arg1
*/
async getConnectionTargets(userId: string, type: string, filters: Record<string, unknown> = {}): Promise<unknown[]> {
if (type === GitLabRepoConnection.CanonicalEventType) {
if (!this.config.gitlab) {
throw new ApiError('GitLab is not configured', ErrCode.DisabledFeature);
}
if (!this.config.checkPermission(userId, "gitlab", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for GitLab', ErrCode.ForbiddenUser);
}
return await GitLabRepoConnection.getConnectionTargets(userId, this.tokenStore, this.config.gitlab, filters);
}
throw new ApiError(`Connection type not known`, ErrCode.NotFound);
}
}

View File

@ -24,6 +24,17 @@ export interface GitLabRepoConnectionState extends IConnectionState {
excludingLabels?: string[];
}
export interface GitLabRepoConnectionInstanceTarget {
name: string;
}
export interface GitLabRepoConnectionProjectTarget {
state: GitLabRepoConnectionState;
name: string;
}
export type GitLabRepoConnectionTarget = GitLabRepoConnectionInstanceTarget|GitLabRepoConnectionProjectTarget;
const log = new LogWrapper("GitLabRepoConnection");
const md = new markdown();
@ -62,6 +73,13 @@ const AllowedEvents: AllowedEventsNames[] = [
"release.created",
];
export interface GitLabTargetFilter {
instance?: string;
parent?: string;
after?: string;
search?: string;
}
function validateState(state: Record<string, unknown>): GitLabRepoConnectionState {
if (typeof state.instance !== "string") {
@ -77,11 +95,12 @@ function validateState(state: Record<string, unknown>): GitLabRepoConnectionStat
if (state.commandPrefix) {
if (typeof state.commandPrefix !== "string") {
throw new ApiError("Expected 'commandPrefix' to be a string", ErrCode.BadValue);
}
if (state.commandPrefix.length < 2 || state.commandPrefix.length > 24) {
} else if (state.commandPrefix.length >= 2 || state.commandPrefix.length <= 24) {
res.commandPrefix = state.commandPrefix;
} else if (state.commandPrefix.length > 0) {
throw new ApiError("Expected 'commandPrefix' to be between 2-24 characters", ErrCode.BadValue);
}
res.commandPrefix = state.commandPrefix;
// Otherwise empty string, ignore.
}
if (state.ignoreHooks && Array.isArray(state.ignoreHooks)) {
if (state.ignoreHooks?.find((ev) => !AllowedEvents.includes(ev))?.length) {
@ -109,8 +128,12 @@ export class GitLabRepoConnection extends CommandConnection {
private readonly debounceMRComments = new Map<string, {comments: number, author: string, timeout: NodeJS.Timeout}>();
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, as: Appservice, tokenStore: UserTokenStore, instance: GitLabInstance, gitlabConfig: BridgeConfigGitLab) {
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, as: Appservice, tokenStore: UserTokenStore, instanceName: string, gitlabConfig: BridgeConfigGitLab) {
const validData = validateState(data);
const instance = gitlabConfig.instances[instanceName];
if (!instance) {
throw Error(`provisionConnection provided an instanceName of ${instanceName} but the instance does not exist`);
}
const client = await tokenStore.getGitLabForUser(requester, instance.url);
if (!client) {
throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser);
@ -158,6 +181,47 @@ export class GitLabRepoConnection extends CommandConnection {
}
}
public static getProvisionerDetails(botUserId: string) {
return {
service: "gitlab",
eventType: GitLabRepoConnection.CanonicalEventType,
type: "GitLabRepo",
botUserId,
}
}
public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, config: BridgeConfigGitLab, filters: GitLabTargetFilter = {}): Promise<GitLabRepoConnectionTarget[]> {
// Search for all repos under the user's control.
if (!filters.instance) {
const results: GitLabRepoConnectionInstanceTarget[] = [];
for (const [name, instance] of Object.entries(config.instances)) {
const client = await tokenStore.getGitLabForUser(userId, instance.url);
if (client) {
results.push({
name,
} as GitLabRepoConnectionInstanceTarget);
}
}
return results;
}
// If we have an instance, search under it.
const instanceUrl = config.instances[filters.instance]?.url;
const client = instanceUrl && await tokenStore.getGitLabForUser(userId, instanceUrl);
if (!client) {
throw new ApiError('Instance is not known or you do not have access to it.', ErrCode.NotFound);
}
const after = filters.after === undefined ? undefined : parseInt(filters.after, 10);
const allProjects = await client.projects.list(AccessLevel.Developer, filters.parent, after, filters.search);
return allProjects.map(p => ({
state: {
instance: filters.instance,
path: p.path_with_namespace,
},
name: p.name,
})) as GitLabRepoConnectionProjectTarget[];
}
constructor(roomId: string,
stateKey: string,
private readonly as: Appservice,
@ -196,16 +260,6 @@ export class GitLabRepoConnection extends CommandConnection {
return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
}
public static getProvisionerDetails(botUserId: string) {
return {
service: "gitlab",
eventType: GitLabRepoConnection.CanonicalEventType,
type: "GitLabRepo",
// TODO: Add ability to configure the bot per connnection type.
botUserId: botUserId,
}
}
public getProvisionerDetails() {
return {
...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId),
@ -224,7 +278,7 @@ export class GitLabRepoConnection extends CommandConnection {
throw Error('Not logged in');
}
const res = await client.issues.create({
id: encodeURIComponent(this.path),
id: this.path,
title,
description,
labels: labels ? labels.split(",") : undefined,
@ -248,7 +302,7 @@ export class GitLabRepoConnection extends CommandConnection {
}
await client.issues.edit({
id: encodeURIComponent(this.state.path),
id: this.state.path,
issue_iid: number,
state_event: "close",
});
@ -512,6 +566,26 @@ ${data.description}`;
}
return false;
}
public async provisionerUpdateConfig(userId: string, config: Record<string, unknown>) {
const validatedConfig = validateState(config);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig
);
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
// TODO: Clean up webhooks
}
}
// Typescript doesn't understand Prototypes very well yet.

View File

@ -63,6 +63,8 @@ export interface IConnection {
/**
* If supported, this is sent when a user attempts to remove the connection from a room. The connection
* state should be removed and any resources should be cleaned away.
* @props purgeRemoteConfig Should the remote configuration for the connection be purged (in the case that
* other connections may be sharing a remote resource).
*/
onRemove?: () => Promise<void>;

View File

@ -97,7 +97,7 @@ export class SetupConnection extends CommandConnection {
if (!path) {
throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid.");
}
const res = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.as, this.tokenStore, instance, this.config.gitlab);
const res = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.as, this.tokenStore, name, this.config.gitlab);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, url, res.stateEventContent);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${path}`);
}

View File

@ -1,17 +1,16 @@
import axios from "axios";
import { GitLabInstance } from "../Config/Config";
import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse, EventsOpts, CreateIssueNoteOpts, CreateIssueNoteResponse, GetProjectResponse, ProjectHook, ProjectHookOpts } from "./Types";
import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse, EventsOpts, CreateIssueNoteOpts, CreateIssueNoteResponse, GetProjectResponse, ProjectHook, ProjectHookOpts, AccessLevel, SimpleProject } from "./Types";
import LogWrapper from "../LogWrapper";
import { URLSearchParams } from "url";
import UserAgent from "../UserAgent";
const log = new LogWrapper("GitLabClient");
type ProjectId = string|number|string[];
function getProjectId(id: ProjectId) {
return encodeURIComponent(Array.isArray(id) ? id.join("/") : id);
}
/**
* A GitLab project used inside a URL may either be the ID of the project, or the encoded path of the project.
*/
type ProjectId = string|number;
export class GitLabClient {
constructor(private instanceUrl: string, private token: string) {
@ -45,7 +44,7 @@ export class GitLabClient {
}
private async createIssue(opts: CreateIssueOpts): Promise<CreateIssueResponse> {
return (await axios.post(`api/v4/projects/${opts.id}/issues`, opts, this.defaultConfig)).data;
return (await axios.post(`api/v4/projects/${encodeURIComponent(opts.id)}/issues`, opts, this.defaultConfig)).data;
}
private async getIssue(opts: GetIssueOpts): Promise<GetIssueResponse> {
@ -58,22 +57,45 @@ export class GitLabClient {
}
private async editIssue(opts: EditIssueOpts): Promise<CreateIssueResponse> {
return (await axios.put(`api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data;
return (await axios.put(`api/v4/projects/${encodeURIComponent(opts.id)}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data;
}
private async getProject(id: ProjectId): Promise<GetProjectResponse> {
try {
return (await axios.get(`api/v4/projects/${getProjectId(id)}`, this.defaultConfig)).data;
return (await axios.get(`api/v4/projects/${encodeURIComponent(id)}`, this.defaultConfig)).data;
} catch (ex) {
log.warn(`Failed to get project:`, ex);
throw ex;
}
}
private async listProjects(minAccess: AccessLevel, inGroup?: ProjectId, idAfter?: number, search?: string): Promise<SimpleProject[]> {
try {
const path = inGroup ? `api/v4/groups/${encodeURIComponent(inGroup)}/projects` : 'api/v4/projects';
return (await axios.get(path, {
...this.defaultConfig,
params: {
archived: false,
min_access_level: minAccess,
simple: true,
pagination: "keyset",
per_page: 50,
order_by: "id",
sort: "asc",
id_after: idAfter,
search,
}
})).data;
} catch (ex) {
log.warn(`Failed to get projects:`, ex);
throw ex;
}
}
private async getProjectHooks(id: ProjectId): Promise<ProjectHook[]> {
try {
return (await axios.get(`api/v4/projects/${getProjectId(id)}/hooks`, this.defaultConfig)).data;
return (await axios.get(`api/v4/projects/${encodeURIComponent(id)}/hooks`, this.defaultConfig)).data;
} catch (ex) {
log.warn(`Failed to get project hooks:`, ex);
throw ex;
@ -82,7 +104,7 @@ export class GitLabClient {
private async addProjectHook(id: ProjectId, opts: ProjectHookOpts): Promise<ProjectHook> {
try {
return (await axios.post(`api/v4/projects/${getProjectId(id)}/hooks`, opts, this.defaultConfig)).data;
return (await axios.post(`api/v4/projects/${encodeURIComponent(id)}/hooks`, opts, this.defaultConfig)).data;
} catch (ex) {
log.warn(`Failed to create project hook:`, ex);
throw ex;
@ -122,6 +144,7 @@ export class GitLabClient {
get projects() {
return {
get: this.getProject.bind(this),
list: this.listProjects.bind(this),
hooks: {
list: this.getProjectHooks.bind(this),
add: this.addProjectHook.bind(this),

View File

@ -219,3 +219,10 @@ export interface ProjectHook extends ProjectHookOpts {
project_id: 3;
created_at?: string;
}
export interface SimpleProject {
id: string;
name: string;
path: string;
path_with_namespace: string;
}

View File

@ -3,7 +3,7 @@ import { AdminRoom } from "../AdminRoom";
import LogWrapper from "../LogWrapper";
import { ApiError, ErrCode } from "../api";
import { BridgeConfig } from "../Config/Config";
import { GetConnectionsForServiceResponse, WidgetConfigurationSection, WidgetConfigurationType } from "./BridgeWidgetInterface";
import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface";
import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { ConnectionManager } from "../ConnectionManager";
@ -37,9 +37,10 @@ export class BridgeWidgetApi {
this.api.addRoute("get", '/v1/:roomId/connections', this.getConnections.bind(this));
this.api.addRoute("get", '/v1/:roomId/connections/:service', this.getConnectionsForService.bind(this));
this.api.addRoute("post", '/v1/:roomId/connections/:type', this.createConnection.bind(this));
// TODO: Ideally this would be a PATCH, but needs https://github.com/matrix-org/matrix-appservice-bridge/pull/397 to land to support PATCH.
this.api.addRoute("put", '/v1/:roomId/connections/:connectionId', this.updateConnection.bind(this));
this.api.addRoute("patch", '/v1/:roomId/connections/:connectionId', this.updateConnection.bind(this));
this.api.addRoute("delete", '/v1/:roomId/connections/:connectionId', this.deleteConnection.bind(this));
this.api.addRoute("get", '/v1/targets/:type', this.getConnectionTargets.bind(this));
}
private async getRoomFromRequest(req: ProvisioningRequest): Promise<AdminRoom> {
@ -72,22 +73,7 @@ export class BridgeWidgetApi {
}
private async getServiceConfig(req: ProvisioningRequest, res: Response<Record<string, unknown>>) {
let config: undefined|Record<string, unknown>;
switch (req.params.service) {
case "generic":
config = this.config.generic?.publicConfig;
break;
default:
throw new ApiError("Not a known service, or service doesn't expose a config", ErrCode.NotFound);
}
if (!config) {
throw new ApiError("Service is not enabled", ErrCode.DisabledFeature);
}
res.send(
config
);
res.send(this.config.getPublicConfigForService(req.params.service));
}
private async getConnectionsForRequest(req: ProvisioningRequest) {
@ -162,7 +148,6 @@ export class BridgeWidgetApi {
res.send(connection.getProvisionerDetails(true));
}
private async deleteConnection(req: ProvisioningRequest, res: Response<{ok: true}>) {
if (!req.userId) {
throw Error('Cannot get connections without a valid userId');
@ -180,4 +165,13 @@ export class BridgeWidgetApi {
await this.connMan.purgeConnection(roomId, connectionId);
res.send({ok: true});
}
private async getConnectionTargets(req: ProvisioningRequest, res: Response) {
if (!req.userId) {
throw Error('Cannot get connections without a valid userId');
}
const type = req.params.type;
const connections = await this.connMan.getConnectionTargets(req.userId, type, req.query);
res.send(connections);
}
}

View File

@ -126,4 +126,9 @@ export default class BridgeAPI {
removeConnection(roomId: string, connectionId: string) {
return this.request('DELETE', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`);
}
getConnectionTargets<R>(type: string, filters?: unknown): Promise<R[]> {
const searchParams = filters && new URLSearchParams(filters as Record<string, string>);
return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? '?' : ''}${searchParams}`);
}
}

View File

@ -15,6 +15,10 @@
cursor: pointer;
}
.button:disabled {
background-color: var(--primary-color-disabled);
}
.remove {
color: #FF5B55;
background-color: transparent;

View File

@ -6,5 +6,5 @@ export function Button(props: { [key: string]: unknown, intent?: string}) {
if (props.intent === "remove") {
className += ` ${ style.remove}`;
}
return <button className={className} {...props} />;
return <button type="button" className={className} {...props} />;
}

View File

@ -0,0 +1,5 @@
.buttonSet {
display: flex;
flex-direction: row;
align-items: flex-start;
}

View File

@ -0,0 +1,9 @@
import { FunctionComponent, h } from "preact";
import style from "./ButtonSet.module.scss";
const ButtonSet: FunctionComponent = (props) => {
return <div className={style.buttonSet}>
{props.children}
</div>;
}
export default ButtonSet;

View File

@ -11,6 +11,7 @@
flex-direction: row;
align-items: flex-start;
padding: 12px;
margin-top: 10px;
cursor: pointer;

View File

@ -1,21 +1,3 @@
.header {
display: flex;
flex-direction: row;
align-items: flex-start;
img {
width: 52px;
height: 52px;
}
h1 {
margin-left: 10px;
font-weight: 600;
font-size: 18px;
line-height: 24px;
}
}
.inputField {
width: 100%;
margin-bottom: 12px;
@ -29,7 +11,13 @@
max-width: calc(100% - 20px);
padding: 0 2px;
color: var(--light-color);
background: #FFF;
background: var(--background-color);
}
ul > label {
position: relative;
left: auto;
margin: auto;
}
label.nopad {
@ -46,10 +34,15 @@
padding: 8px 12px;
color: var(--light-color);
}
}
.buttonSet {
display: flex;
flex-direction: row;
align-items: flex-start;
select {
border: 1px solid #E3E8F0;
box-sizing: border-box;
border-radius: 8px;
width: 100%;
font-size: 14px;
background: var(--background-color);
padding: 8px 12px;
color: var(--light-color);
}
}

View File

@ -0,0 +1,17 @@
import { h, FunctionComponent } from "preact";
import style from "./InputField.module.scss";
interface Props {
visible?: boolean;
label?: string;
noPadding: boolean;
}
const InputField: FunctionComponent<Props> = ({ children, visible = true, label, noPadding }) => {
return visible && <div className={style.inputField}>
{label && <label className={noPadding ? style.nopad : ""}>{label}</label>}
{children}
</div>;
};
export default InputField;

View File

@ -5,6 +5,7 @@ import BridgeAPI from "../BridgeAPI";
import style from "./RoomConfigView.module.scss";
import { ConnectionCard } from "./ConnectionCard";
import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig";
import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig";
interface IProps {
@ -15,24 +16,31 @@ interface IProps {
}
export default function RoomConfigView(props: IProps) {
const [ activeConnectionType, setActiveConnectionType ] = useState<null|"generic">(null);
const [ activeConnectionType, setActiveConnectionType ] = useState<null|"generic"|"gitlab">(null);
let content;
if (activeConnectionType) {
content = <>
{activeConnectionType === "generic" && <GenericWebhookConfig roomId={props.roomId} api={props.bridgeApi} />}
{activeConnectionType === "gitlab" && <GitlabRepoConfig roomId={props.roomId} api={props.bridgeApi} />}
</>;
} else {
content = <>
<section>
<h2> Integrations </h2>
{props.supportedServices["gitlab"] && <ConnectionCard
imageSrc="./icons/gitlab.png"
serviceName="GitLab"
description="Connect the room to a GitLab project"
onClick={() => setActiveConnectionType("gitlab")}
/>}
{props.supportedServices["generic"] && <ConnectionCard
imageSrc="./icons/webhook.png"
serviceName="Generic Webhook"
description="Create a webhook which can be used to connect any service to Matrix"
onClick={() => setActiveConnectionType("generic")}
>Setup Webhook Connection</ConnectionCard>}
/>}
</section>
</>;
}

View File

@ -1,13 +1,13 @@
import { h, FunctionComponent, Fragment, createRef } from "preact";
import { h, FunctionComponent, createRef } from "preact";
import { useCallback, useState } from "preact/hooks"
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { Button } from "../Button";
import { ListItem } from "../ListItem";
import BridgeAPI from "../../BridgeAPI";
import ErrorPane from "../ErrorPane";
import { GenericHookConnectionState, GenericHookResponseItem } from "../../../src/Connections/GenericHook";
import style from "./GenericWebhookConfig.module.scss";
import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig";
import InputField from "../InputField";
import ButtonSet from "../ButtonSet";
const EXAMPLE_SCRIPT = `if (data.counter === undefined) {
result = {
@ -29,52 +29,37 @@ const EXAMPLE_SCRIPT = `if (data.counter === undefined) {
const DOCUMENTATION_LINK = "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html#script-api";
const ConnectionConfiguration: FunctionComponent<{
serviceConfig: ServiceConfig,
existingConnection?: GenericHookResponseItem,
onSave: (newConfig: GenericHookConnectionState) => void,
onRemove?: () => void
}> = ({serviceConfig, existingConnection, onSave, onRemove}) => {
const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ServiceConfig, GenericHookResponseItem, GenericHookConnectionState>> = ({serviceConfig, existingConnection, onSave, onRemove}) => {
const [transFn, setTransFn] = useState<string>(existingConnection?.config.transformationFunction as string || EXAMPLE_SCRIPT);
const [transFnEnabled, setTransFnEnabled] = useState(serviceConfig.allowJsTransformationFunctions && !!existingConnection?.config.transformationFunction);
const nameRef = createRef<HTMLInputElement>();
const onSaveClick = useCallback(() => {
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
const handleSave = useCallback((evt: Event) => {
evt.preventDefault();
if (!canEdit) {
return;
}
onSave({
name: nameRef?.current?.value || existingConnection?.config.name,
...(transFnEnabled ? { transformationFunction: transFn } : undefined),
});
if (!existingConnection) {
// Clear fields
nameRef.current.value = "";
setTransFn(EXAMPLE_SCRIPT);
setTransFnEnabled(false);
}
}, [onSave, nameRef, transFn, setTransFnEnabled, setTransFn, existingConnection, transFnEnabled]);
}, [canEdit, onSave, nameRef, transFn, existingConnection, transFnEnabled]);
const onRemoveClick = useCallback(() => {
onRemove();
}, [onRemove]);
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
return <div>
{ !existingConnection && <div className={style.inputField}>
<label>Friendly name</label>
return <form onSubmit={handleSave}>
<InputField visible={!existingConnection} label="Friendly name" noPadding={true}>
<input ref={nameRef} disabled={!canEdit} placeholder="My webhook" type="text" value={existingConnection?.config.name} />
</div> }
</InputField>
{!!existingConnection && <div className={style.inputField}>
<label>URL</label>
<InputField visible={!!existingConnection} label="URL" noPadding={true}>
<input disabled={true} placeholder="URL hidden" type="text" value={existingConnection?.secrets?.url || ""} />
</div>}
</InputField>
{ serviceConfig.allowJsTransformationFunctions && <div className={style.inputField}>
<label className={style.nopad}>Enable Transformation JavaScript</label>
<input disabled={!canEdit} type="checkbox" checked={transFnEnabled} onChange={() => setTransFnEnabled(!transFnEnabled)} />
</div> }
<InputField visible={serviceConfig.allowJsTransformationFunctions} label="Enable Transformation JavaScript" noPadding={true}>
<input disabled={!canEdit} type="checkbox" checked={transFnEnabled} onChange={useCallback(() => setTransFnEnabled(v => !v), [])} />
</InputField>
{ transFnEnabled && <div className={style.inputField}>
<InputField visible={transFnEnabled} noPadding={true}>
<CodeMirror
value={transFn}
extensions={[javascript({ })]}
@ -83,14 +68,12 @@ const ConnectionConfiguration: FunctionComponent<{
}}
/>
<p> See the <a target="_blank" rel="noopener noreferrer" href={DOCUMENTATION_LINK}>documentation</a> for help writing transformation functions </p>
</div>}
<div className={style.buttonSet}>
{ canEdit && <Button onClick={onSaveClick}>{ existingConnection ? "Save" : "Add webhook"}</Button>}
{ canEdit && existingConnection && <Button intent="remove" onClick={onRemoveClick}>Remove webhook</Button>}
</div>
</div>;
</InputField>
<ButtonSet>
{ canEdit && <Button type="submit">{ existingConnection ? "Save" : "Add webhook" }</Button>}
{ canEdit && existingConnection && <Button intent="remove" onClick={onRemove}>Remove webhook</Button>}
</ButtonSet>
</form>;
};
interface IGenericWebhookConfigProps {
@ -102,85 +85,24 @@ interface ServiceConfig {
allowJsTransformationFunctions: boolean
}
export const GenericWebhookConfig: FunctionComponent<IGenericWebhookConfigProps> = ({ api, roomId }) => {
const [ error, setError ] = useState<null|string>(null);
const [ connections, setConnections ] = useState<GenericHookResponseItem[]|null>(null);
const [ serviceConfig, setServiceConfig ] = useState<{allowJsTransformationFunctions: boolean}|null>(null);
const [ canEditRoom, setCanEditRoom ] = useState<boolean>(false);
if (connections === null) {
api.getConnectionsForService<GenericHookResponseItem>(roomId, 'generic')
.then(res => {
setCanEditRoom(res.canEdit);
setConnections(res.connections);
})
.catch(ex => {
console.warn("Failed to fetch existing connections", ex);
setError("Failed to fetch existing connections");
});
}
if (serviceConfig === null) {
api.getServiceConfig<ServiceConfig>('generic')
.then((res) => setServiceConfig(res))
.catch(ex => {
console.warn("Failed to fetch service config", ex);
setError("Failed to fetch service config");
});
}
return <>
<main>
{
error && <ErrorPane header="Error">{error}</ErrorPane>
}
<header className={style.header}>
<img src="./icons/webhook.png" />
<h1>Generic Webhooks</h1>
</header>
{ canEditRoom && <section>
<h2>Create new webhook</h2>
{serviceConfig && <ConnectionConfiguration
serviceConfig={serviceConfig}
onSave={(config) => {
api.createConnection(roomId, "uk.half-shot.matrix-hookshot.generic.hook", config).then(() => {
// Force reload
setConnections(null);
}).catch(ex => {
console.warn("Failed to create connection", ex);
setError("Failed to create connection");
});
}}
/>}
</section>}
<section>
<h2>{ canEditRoom ? "Your webhooks" : "Configured webhooks" }</h2>
{ serviceConfig && connections?.map(c => <ListItem key={c.id} text={c.config.name as string}>
<ConnectionConfiguration
serviceConfig={serviceConfig}
existingConnection={c}
onSave={(config) => {
api.updateConnection(roomId, c.id, config).then(() => {
// Force reload
setConnections(null);
}).catch(ex => {
console.warn("Failed to create connection", ex);
setError("Failed to create connection");
});
}}
onRemove={() => {
api.removeConnection(roomId, c.id).then(() => {
setConnections(connections.filter(conn => c.id !== conn.id));
}).catch(ex => {
console.warn("Failed to remove connection", ex);
setError("Failed to remove connection");
});
}}
/>
</ListItem>)
}
</section>
</main>
</>;
const RoomConfigText = {
header: 'Generic Webhooks',
createNew: 'Create new webhook',
listCanEdit: 'Your webhooks',
listCantEdit: 'Configured webhooks',
};
const RoomConfigListItemFunc = (c: GenericHookResponseItem) => c.config.name;
export const GenericWebhookConfig: FunctionComponent<IGenericWebhookConfigProps> = ({ api, roomId }) => {
return <RoomConfig<ServiceConfig, GenericHookResponseItem, GenericHookConnectionState>
headerImg="./icons/webhook.png"
api={api}
roomId={roomId}
type="generic"
connectionEventType="uk.half-shot.matrix-hookshot.generic.hook"
text={RoomConfigText}
listItemName={RoomConfigListItemFunc}
connectionConfigComponent={ConnectionConfiguration}
/>;
};

View File

@ -0,0 +1,205 @@
import { h, FunctionComponent, createRef } from "preact";
import { useState, useCallback, useEffect, useMemo } from "preact/hooks";
import BridgeAPI from "../../BridgeAPI";
import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig";
import { GitLabRepoConnectionState, GitLabRepoResponseItem, GitLabTargetFilter, GitLabRepoConnectionTarget, GitLabRepoConnectionProjectTarget, GitLabRepoConnectionInstanceTarget } from "../../../src/Connections/GitlabRepo";
import InputField from "../InputField";
import ButtonSet from "../ButtonSet";
import { Button } from "../Button";
import ErrorPane from "../ErrorPane";
const EventType = "uk.half-shot.matrix-hookshot.gitlab.repository";
const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: GitLabRepoConnectionState) => void}> = ({api, onPicked}) => {
const [filter, setFilter] = useState<GitLabTargetFilter>({});
const [results, setResults] = useState<GitLabRepoConnectionProjectTarget[]|null>(null);
const [instances, setInstances] = useState<GitLabRepoConnectionInstanceTarget[]|null>(null);
const [debounceTimer, setDebounceTimer] = useState<number>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string|null>(null);
const [searchError, setSearchError] = useState<string|null>(null);
const searchFn = useCallback(async() => {
try {
const res = await api.getConnectionTargets<GitLabRepoConnectionTarget>(EventType, filter);
if (!filter.instance) {
setInstances(res);
if (res[0]) {
setFilter({instance: res[0].name, search: ""});
}
} else {
setResults(res as GitLabRepoConnectionProjectTarget[]);
}
} catch (ex) {
setSearchError("There was an error fetching search results.");
// Rather than raising an error, let's just log and let the user retry a query.
console.warn(`Failed to get connection targets from query:`, ex);
}
}, [api, filter]);
const updateSearchFn = useCallback((evt: InputEvent) => {
setFilter(filterState => ({...filterState, search: (evt.target as HTMLInputElement).value }));
}, [setFilter]);
useEffect(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Browser types
setDebounceTimer(setTimeout(searchFn, 500) as unknown as number);
return () => {
clearTimeout(debounceTimer);
}
// Things break if we depend on the thing we are clearing.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchFn, filter, instances]);
useEffect(() => {
const hasResult = results?.find(n => n.name === filter.search);
if (hasResult) {
onPicked(hasResult.state);
setCurrentProjectPath(hasResult.state.path);
}
}, [onPicked, results, filter]);
const onInstancePicked = useCallback((evt: InputEvent) => {
// Reset the search string.
setFilter({
instance: (evt.target as HTMLSelectElement).selectedOptions[0].value,
search: ""
});
}, []);
const instanceListResults = useMemo(
() => instances?.map(i => <option key={i.name}>{i.name}</option>),
[instances]
);
const projectListResults = useMemo(
() => results?.map(i => <option path={i.state.path} value={i.name} key={i.name} />),
[results]
);
return <div>
{instances === null && <p> Loading GitLab instances. </p>}
{instances?.length === 0 && <p> You are not logged into any GitLab instances. </p>}
{searchError && <ErrorPane> {searchError} </ErrorPane> }
<InputField visible={instances?.length > 0} label="GitLab Instance" noPadding={true}>
<select onChange={onInstancePicked}>
{instanceListResults}
</select>
</InputField>
<InputField visible={instances?.length > 0} label="Project" noPadding={true}>
<small>{currentProjectPath ?? ""}</small>
<input onChange={updateSearchFn} value={filter.search} list="gitlab-projects" type="text" />
<datalist id="gitlab-projects">
{projectListResults}
</datalist>
</InputField>
</div>;
}
const EventCheckbox: FunctionComponent<{
ignoredHooks: string[],
onChange: (evt: HTMLInputElement) => void,
eventName: string,
parentEvent?: string,
}> = ({ignoredHooks, onChange, eventName, parentEvent, children}) => {
return <li>
<label>
<input
disabled={parentEvent && ignoredHooks.includes(parentEvent)}
type="checkbox"
x-event-name={eventName}
checked={!ignoredHooks.includes(eventName)}
onChange={onChange} />
{ children }
</label>
</li>;
};
const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<never, GitLabRepoResponseItem, GitLabRepoConnectionState>> = ({api, existingConnection, onSave, onRemove }) => {
const [ignoredHooks, setIgnoredHooks] = useState<string[]>(existingConnection?.config.ignoreHooks || []);
const toggleIgnoredHook = useCallback(evt => {
const key = (evt.target as HTMLElement).getAttribute('x-event-name');
setIgnoredHooks(ignoredHooks => (
ignoredHooks.includes(key) ? ignoredHooks.filter(k => k !== key) : [...ignoredHooks, key]
));
}, []);
const [newInstanceState, setNewInstanceState] = useState<GitLabRepoConnectionState|null>(null);
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
const commandPrefixRef = createRef<HTMLInputElement>();
const handleSave = useCallback((evt: Event) => {
evt.preventDefault();
if (!canEdit || !existingConnection && !newInstanceState) {
return;
}
onSave({
...(existingConnection?.config || newInstanceState),
ignoreHooks: ignoredHooks as any[],
commandPrefix: commandPrefixRef.current.value,
});
}, [canEdit, existingConnection, newInstanceState, ignoredHooks, commandPrefixRef, onSave]);
return <form onSubmit={handleSave}>
{!existingConnection && <ConnectionSearch api={api} onPicked={setNewInstanceState} />}
<InputField visible={!!existingConnection} label="GitLab Instance" noPadding={true}>
<input disabled={true} type="text" value={existingConnection?.config.instance} />
</InputField>
<InputField visible={!!existingConnection} label="Project" noPadding={true}>
<input disabled={true} type="text" value={existingConnection?.config.path} />
</InputField>
<InputField visible={!!existingConnection || !!newInstanceState} ref={commandPrefixRef} label="Command Prefix" noPadding={true}>
<input type="text" value={existingConnection?.config.commandPrefix} placeholder="!gl" />
</InputField>
<InputField visible={!!existingConnection || !!newInstanceState} label="Events" noPadding={true}>
<p>Choose which event should send a notification to the room</p>
<ul>
<EventCheckbox ignoredHooks={ignoredHooks} eventName="merge_request" onChange={toggleIgnoredHook}>Merge requests</EventCheckbox>
<ul>
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.open" onChange={toggleIgnoredHook}>Opened</EventCheckbox>
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.close" onChange={toggleIgnoredHook}>Closed</EventCheckbox>
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.merge" onChange={toggleIgnoredHook}>Merged</EventCheckbox>
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.review" onChange={toggleIgnoredHook}>Reviewed</EventCheckbox>
</ul>
<EventCheckbox ignoredHooks={ignoredHooks} eventName="push" onChange={toggleIgnoredHook}>Pushes</EventCheckbox>
<EventCheckbox ignoredHooks={ignoredHooks} eventName="tag_push" onChange={toggleIgnoredHook}>Tag pushes</EventCheckbox>
<EventCheckbox ignoredHooks={ignoredHooks} eventName="wiki" onChange={toggleIgnoredHook}>Wiki page updates</EventCheckbox>
<EventCheckbox ignoredHooks={ignoredHooks} eventName="release" onChange={toggleIgnoredHook}>Releases</EventCheckbox>
</ul>
</InputField>
<ButtonSet>
{ canEdit && <Button type="submit" disabled={!existingConnection && !newInstanceState}>{ existingConnection ? "Save" : "Add project" }</Button>}
{ canEdit && existingConnection && <Button intent="remove" onClick={onRemove}>Remove project</Button>}
</ButtonSet>
</form>;
};
interface IGenericWebhookConfigProps {
api: BridgeAPI,
roomId: string,
}
const RoomConfigText = {
header: 'GitLab Projects',
createNew: 'Add new GitLab project',
listCanEdit: 'Your connected projects',
listCantEdit: 'Connected projects',
};
const RoomConfigListItemFunc = (c: GitLabRepoResponseItem) => c.config.path;
export const GitlabRepoConfig: FunctionComponent<IGenericWebhookConfigProps> = ({ api, roomId }) => {
return <RoomConfig<never, GitLabRepoResponseItem, GitLabRepoConnectionState>
headerImg="./icons/gitlab.png"
api={api}
roomId={roomId}
type="gitlab"
text={RoomConfigText}
listItemName={RoomConfigListItemFunc}
connectionEventType={EventType}
connectionConfigComponent={ConnectionConfiguration}
/>;
};

View File

@ -0,0 +1,17 @@
.header {
display: flex;
flex-direction: row;
align-items: flex-start;
img {
width: 52px;
height: 52px;
}
h1 {
margin-left: 10px;
font-weight: 600;
font-size: 18px;
line-height: 24px;
}
}

View File

@ -0,0 +1,119 @@
import { h, FunctionComponent } from "preact";
import { useCallback, useEffect, useReducer, useState } from "preact/hooks"
import { ListItem } from "../ListItem";
import BridgeAPI from "../../BridgeAPI";
import ErrorPane from "../ErrorPane";
import style from "./RoomConfig.module.scss";
import { GetConnectionsResponseItem } from "../../../src/provisioning/api";
export interface ConnectionConfigurationProps<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState> {
serviceConfig: SConfig;
onSave: (newConfig: ConnectionState) => void,
existingConnection?: ConnectionType;
onRemove?: () => void,
api: BridgeAPI;
}
interface IRoomConfigProps<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState> {
api: BridgeAPI;
roomId: string;
type: string;
headerImg: string;
text: {
header: string;
createNew: string;
listCanEdit: string;
listCantEdit: string;
};
connectionEventType: string;
listItemName: (c: ConnectionType) => string,
connectionConfigComponent: FunctionComponent<ConnectionConfigurationProps<SConfig, ConnectionType, ConnectionState>>;
}
export const RoomConfig = function<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState>(props: IRoomConfigProps<SConfig, ConnectionType, ConnectionState>) {
const { api, roomId, type, headerImg, text, listItemName, connectionEventType } = props;
const ConnectionConfigComponent = props.connectionConfigComponent;
const [ error, setError ] = useState<null|string>(null);
const [ connections, setConnections ] = useState<ConnectionType[]|null>(null);
const [ serviceConfig, setServiceConfig ] = useState<SConfig|null>(null);
const [ canEditRoom, setCanEditRoom ] = useState<boolean>(false);
// We need to increment this every time we create a connection in order to properly reset the state.
const [ newConnectionKey, incrementConnectionKey ] = useReducer<number, undefined>(n => n+1, 0);
useEffect(() => {
api.getConnectionsForService<ConnectionType>(roomId, type).then(res => {
setCanEditRoom(res.canEdit);
setConnections(res.connections);
}).catch(ex => {
console.warn("Failed to fetch existing connections", ex);
setError("Failed to fetch existing connections");
});
}, [api, roomId, type, newConnectionKey]);
useEffect(() => {
api.getServiceConfig<SConfig>(type)
.then(setServiceConfig)
.catch(ex => {
console.warn("Failed to fetch service config", ex);
setError("Failed to fetch service config");
})
}, [api, type]);
const handleSaveOnCreation = useCallback((config) => {
api.createConnection(roomId, connectionEventType, config).then(() => {
// Force reload
incrementConnectionKey(undefined);
}).catch(ex => {
console.warn("Failed to create connection", ex);
setError("Failed to create connection");
});
}, [api, roomId, connectionEventType]);
return <main>
{
error && <ErrorPane header="Error">{error}</ErrorPane>
}
<header className={style.header}>
<img src={headerImg} />
<h1>{text.header}</h1>
</header>
{ canEditRoom && <section>
<h2>{text.createNew}</h2>
{serviceConfig && <ConnectionConfigComponent
key={newConnectionKey}
api={api}
serviceConfig={serviceConfig}
onSave={handleSaveOnCreation}
/>}
</section>}
<section>
<h2>{ canEditRoom ? text.listCanEdit : text.listCantEdit }</h2>
{ serviceConfig && connections?.map(c => <ListItem key={c.id} text={listItemName(c)}>
<ConnectionConfigComponent
api={api}
serviceConfig={serviceConfig}
existingConnection={c}
onSave={(config) => {
api.updateConnection(roomId, c.id, config).then(() => {
// Force reload
incrementConnectionKey(undefined);
}).catch(ex => {
console.warn("Failed to create connection", ex);
setError("Failed to create connection");
});
}}
onRemove={() => {
api.removeConnection(roomId, c.id).then(() => {
setConnections(conn => conn.filter(conn => c.id !== conn.id));
}).catch(ex => {
console.warn("Failed to remove connection", ex);
setError("Failed to remove connection");
});
}}
/>
</ListItem>)
}
</section>
</main>;
};

BIN
web/icons/gitlab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -5,6 +5,7 @@
--foreground-color: #17191C;
--light-color: #737D8C;
--primary-color: #0DBD8B;
--primary-color-disabled: #0dbd8baf;
}
// @media (prefers-color-scheme: dark) {