mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
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:
parent
f56545bf7c
commit
8808385b6d
1
changelog.d/320.feature
Normal file
1
changelog.d/320.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Add support for GitLab in the widgets configuration UI.
|
@ -8,6 +8,7 @@ import { GitHubRepoConnectionOptions } from "../Connections/GithubRepo";
|
|||||||
import { BridgeConfigActorPermission, BridgePermissions } from "../libRs";
|
import { BridgeConfigActorPermission, BridgePermissions } from "../libRs";
|
||||||
import LogWrapper from "../LogWrapper";
|
import LogWrapper from "../LogWrapper";
|
||||||
import { ConfigError } from "../errors";
|
import { ConfigError } from "../errors";
|
||||||
|
import { ApiError, ErrCode } from "../api";
|
||||||
|
|
||||||
const log = new LogWrapper("Config");
|
const log = new LogWrapper("Config");
|
||||||
|
|
||||||
@ -185,6 +186,14 @@ export class BridgeConfigGitLab {
|
|||||||
this.userIdPrefix = yaml.userIdPrefix || "_gitlab_";
|
this.userIdPrefix = yaml.userIdPrefix || "_gitlab_";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@hideKey()
|
||||||
|
public get publicConfig() {
|
||||||
|
return {
|
||||||
|
userIdPrefix: this.userIdPrefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public getInstanceByProjectUrl(url: string): {name: string, instance: GitLabInstance}|null {
|
public getInstanceByProjectUrl(url: string): {name: string, instance: GitLabInstance}|null {
|
||||||
for (const [name, instance] of Object.entries(this.instances)) {
|
for (const [name, instance] of Object.entries(this.instances)) {
|
||||||
if (url.startsWith(instance.url)) {
|
if (url.startsWith(instance.url)) {
|
||||||
@ -537,6 +546,25 @@ export class BridgeConfig {
|
|||||||
return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]);
|
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}) {
|
static async parseConfig(filename: string, env: {[key: string]: string|undefined}) {
|
||||||
const file = await fs.readFile(filename, "utf-8");
|
const file = await fs.readFile(filename, "utf-8");
|
||||||
return new BridgeConfig(YAML.parse(file), env);
|
return new BridgeConfig(YAML.parse(file), env);
|
||||||
|
@ -75,13 +75,13 @@ export class ConnectionManager extends EventEmitter {
|
|||||||
log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with ${data}`);
|
log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with ${data}`);
|
||||||
const existingConnections = await this.getAllConnectionsForRoom(roomId);
|
const existingConnections = await this.getAllConnectionsForRoom(roomId);
|
||||||
if (JiraProjectConnection.EventTypes.includes(type)) {
|
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)) {
|
if (existingConnections.find(c => c instanceof JiraProjectConnection)) {
|
||||||
// TODO: Support this.
|
// TODO: Support this.
|
||||||
throw Error("Cannot support multiple connections of the same type yet");
|
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)) {
|
if (!this.config.checkPermission(userId, "jira", BridgePermissionLevel.manageConnections)) {
|
||||||
throw new ApiError('User is not permitted to provision connections for Jira', ErrCode.ForbiddenUser);
|
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;
|
return res.connection;
|
||||||
}
|
}
|
||||||
if (GitHubRepoConnection.EventTypes.includes(type)) {
|
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)) {
|
if (existingConnections.find(c => c instanceof GitHubRepoConnection)) {
|
||||||
// TODO: Support this.
|
// TODO: Support this.
|
||||||
throw Error("Cannot support multiple connections of the same type yet");
|
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)) {
|
if (!this.config.checkPermission(userId, "github", BridgePermissionLevel.manageConnections)) {
|
||||||
throw new ApiError('User is not permitted to provision connections for GitHub', ErrCode.ForbiddenUser);
|
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 (GenericHookConnection.EventTypes.includes(type)) {
|
||||||
if (!this.config.generic) {
|
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)) {
|
if (!this.config.checkPermission(userId, "webhooks", BridgePermissionLevel.manageConnections)) {
|
||||||
throw new ApiError('User is not permitted to provision connections for generic webhooks', ErrCode.ForbiddenUser);
|
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);
|
this.push(res.connection);
|
||||||
return 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`);
|
throw new ApiError(`Connection type not known`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,4 +465,23 @@ export class ConnectionManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.enabledForProvisioning[details.type] = details;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,17 @@ export interface GitLabRepoConnectionState extends IConnectionState {
|
|||||||
excludingLabels?: string[];
|
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 log = new LogWrapper("GitLabRepoConnection");
|
||||||
const md = new markdown();
|
const md = new markdown();
|
||||||
|
|
||||||
@ -62,6 +73,13 @@ const AllowedEvents: AllowedEventsNames[] = [
|
|||||||
"release.created",
|
"release.created",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export interface GitLabTargetFilter {
|
||||||
|
instance?: string;
|
||||||
|
parent?: string;
|
||||||
|
after?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function validateState(state: Record<string, unknown>): GitLabRepoConnectionState {
|
function validateState(state: Record<string, unknown>): GitLabRepoConnectionState {
|
||||||
if (typeof state.instance !== "string") {
|
if (typeof state.instance !== "string") {
|
||||||
@ -77,11 +95,12 @@ function validateState(state: Record<string, unknown>): GitLabRepoConnectionStat
|
|||||||
if (state.commandPrefix) {
|
if (state.commandPrefix) {
|
||||||
if (typeof state.commandPrefix !== "string") {
|
if (typeof state.commandPrefix !== "string") {
|
||||||
throw new ApiError("Expected 'commandPrefix' to be a string", ErrCode.BadValue);
|
throw new ApiError("Expected 'commandPrefix' to be a string", ErrCode.BadValue);
|
||||||
}
|
} else if (state.commandPrefix.length >= 2 || state.commandPrefix.length <= 24) {
|
||||||
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);
|
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 && Array.isArray(state.ignoreHooks)) {
|
||||||
if (state.ignoreHooks?.find((ev) => !AllowedEvents.includes(ev))?.length) {
|
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}>();
|
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 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);
|
const client = await tokenStore.getGitLabForUser(requester, instance.url);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser);
|
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,
|
constructor(roomId: string,
|
||||||
stateKey: string,
|
stateKey: string,
|
||||||
private readonly as: Appservice,
|
private readonly as: Appservice,
|
||||||
@ -196,16 +260,6 @@ export class GitLabRepoConnection extends CommandConnection {
|
|||||||
return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
|
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() {
|
public getProvisionerDetails() {
|
||||||
return {
|
return {
|
||||||
...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId),
|
...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId),
|
||||||
@ -224,7 +278,7 @@ export class GitLabRepoConnection extends CommandConnection {
|
|||||||
throw Error('Not logged in');
|
throw Error('Not logged in');
|
||||||
}
|
}
|
||||||
const res = await client.issues.create({
|
const res = await client.issues.create({
|
||||||
id: encodeURIComponent(this.path),
|
id: this.path,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
labels: labels ? labels.split(",") : undefined,
|
labels: labels ? labels.split(",") : undefined,
|
||||||
@ -248,7 +302,7 @@ export class GitLabRepoConnection extends CommandConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.issues.edit({
|
await client.issues.edit({
|
||||||
id: encodeURIComponent(this.state.path),
|
id: this.state.path,
|
||||||
issue_iid: number,
|
issue_iid: number,
|
||||||
state_event: "close",
|
state_event: "close",
|
||||||
});
|
});
|
||||||
@ -512,6 +566,26 @@ ${data.description}`;
|
|||||||
}
|
}
|
||||||
return false;
|
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.
|
// Typescript doesn't understand Prototypes very well yet.
|
||||||
|
@ -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
|
* 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.
|
* 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>;
|
onRemove?: () => Promise<void>;
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ export class SetupConnection extends CommandConnection {
|
|||||||
if (!path) {
|
if (!path) {
|
||||||
throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid.");
|
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.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, url, res.stateEventContent);
|
||||||
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${path}`);
|
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${path}`);
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,16 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { GitLabInstance } from "../Config/Config";
|
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 LogWrapper from "../LogWrapper";
|
||||||
import { URLSearchParams } from "url";
|
import { URLSearchParams } from "url";
|
||||||
import UserAgent from "../UserAgent";
|
import UserAgent from "../UserAgent";
|
||||||
|
|
||||||
const log = new LogWrapper("GitLabClient");
|
const log = new LogWrapper("GitLabClient");
|
||||||
|
|
||||||
type ProjectId = string|number|string[];
|
/**
|
||||||
|
* A GitLab project used inside a URL may either be the ID of the project, or the encoded path of the project.
|
||||||
function getProjectId(id: ProjectId) {
|
*/
|
||||||
return encodeURIComponent(Array.isArray(id) ? id.join("/") : id);
|
type ProjectId = string|number;
|
||||||
}
|
|
||||||
export class GitLabClient {
|
export class GitLabClient {
|
||||||
constructor(private instanceUrl: string, private token: string) {
|
constructor(private instanceUrl: string, private token: string) {
|
||||||
|
|
||||||
@ -45,7 +44,7 @@ export class GitLabClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async createIssue(opts: CreateIssueOpts): Promise<CreateIssueResponse> {
|
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> {
|
private async getIssue(opts: GetIssueOpts): Promise<GetIssueResponse> {
|
||||||
@ -58,22 +57,45 @@ export class GitLabClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async editIssue(opts: EditIssueOpts): Promise<CreateIssueResponse> {
|
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> {
|
private async getProject(id: ProjectId): Promise<GetProjectResponse> {
|
||||||
try {
|
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) {
|
} catch (ex) {
|
||||||
log.warn(`Failed to get project:`, ex);
|
log.warn(`Failed to get project:`, ex);
|
||||||
throw 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[]> {
|
private async getProjectHooks(id: ProjectId): Promise<ProjectHook[]> {
|
||||||
try {
|
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) {
|
} catch (ex) {
|
||||||
log.warn(`Failed to get project hooks:`, ex);
|
log.warn(`Failed to get project hooks:`, ex);
|
||||||
throw ex;
|
throw ex;
|
||||||
@ -82,7 +104,7 @@ export class GitLabClient {
|
|||||||
|
|
||||||
private async addProjectHook(id: ProjectId, opts: ProjectHookOpts): Promise<ProjectHook> {
|
private async addProjectHook(id: ProjectId, opts: ProjectHookOpts): Promise<ProjectHook> {
|
||||||
try {
|
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) {
|
} catch (ex) {
|
||||||
log.warn(`Failed to create project hook:`, ex);
|
log.warn(`Failed to create project hook:`, ex);
|
||||||
throw ex;
|
throw ex;
|
||||||
@ -122,6 +144,7 @@ export class GitLabClient {
|
|||||||
get projects() {
|
get projects() {
|
||||||
return {
|
return {
|
||||||
get: this.getProject.bind(this),
|
get: this.getProject.bind(this),
|
||||||
|
list: this.listProjects.bind(this),
|
||||||
hooks: {
|
hooks: {
|
||||||
list: this.getProjectHooks.bind(this),
|
list: this.getProjectHooks.bind(this),
|
||||||
add: this.addProjectHook.bind(this),
|
add: this.addProjectHook.bind(this),
|
||||||
|
@ -219,3 +219,10 @@ export interface ProjectHook extends ProjectHookOpts {
|
|||||||
project_id: 3;
|
project_id: 3;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimpleProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
path_with_namespace: string;
|
||||||
|
}
|
@ -3,7 +3,7 @@ import { AdminRoom } from "../AdminRoom";
|
|||||||
import LogWrapper from "../LogWrapper";
|
import LogWrapper from "../LogWrapper";
|
||||||
import { ApiError, ErrCode } from "../api";
|
import { ApiError, ErrCode } from "../api";
|
||||||
import { BridgeConfig } from "../Config/Config";
|
import { BridgeConfig } from "../Config/Config";
|
||||||
import { GetConnectionsForServiceResponse, WidgetConfigurationSection, WidgetConfigurationType } from "./BridgeWidgetInterface";
|
import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface";
|
||||||
import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge";
|
import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge";
|
||||||
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
|
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
|
||||||
import { ConnectionManager } from "../ConnectionManager";
|
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', this.getConnections.bind(this));
|
||||||
this.api.addRoute("get", '/v1/:roomId/connections/:service', this.getConnectionsForService.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));
|
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("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("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> {
|
private async getRoomFromRequest(req: ProvisioningRequest): Promise<AdminRoom> {
|
||||||
@ -72,22 +73,7 @@ export class BridgeWidgetApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getServiceConfig(req: ProvisioningRequest, res: Response<Record<string, unknown>>) {
|
private async getServiceConfig(req: ProvisioningRequest, res: Response<Record<string, unknown>>) {
|
||||||
let config: undefined|Record<string, unknown>;
|
res.send(this.config.getPublicConfigForService(req.params.service));
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getConnectionsForRequest(req: ProvisioningRequest) {
|
private async getConnectionsForRequest(req: ProvisioningRequest) {
|
||||||
@ -162,7 +148,6 @@ export class BridgeWidgetApi {
|
|||||||
res.send(connection.getProvisionerDetails(true));
|
res.send(connection.getProvisionerDetails(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async deleteConnection(req: ProvisioningRequest, res: Response<{ok: true}>) {
|
private async deleteConnection(req: ProvisioningRequest, res: Response<{ok: true}>) {
|
||||||
if (!req.userId) {
|
if (!req.userId) {
|
||||||
throw Error('Cannot get connections without a valid userId');
|
throw Error('Cannot get connections without a valid userId');
|
||||||
@ -180,4 +165,13 @@ export class BridgeWidgetApi {
|
|||||||
await this.connMan.purgeConnection(roomId, connectionId);
|
await this.connMan.purgeConnection(roomId, connectionId);
|
||||||
res.send({ok: true});
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,4 +126,9 @@ export default class BridgeAPI {
|
|||||||
removeConnection(roomId: string, connectionId: string) {
|
removeConnection(roomId: string, connectionId: string) {
|
||||||
return this.request('DELETE', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`);
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
@ -15,6 +15,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
.remove {
|
.remove {
|
||||||
color: #FF5B55;
|
color: #FF5B55;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
@ -6,5 +6,5 @@ export function Button(props: { [key: string]: unknown, intent?: string}) {
|
|||||||
if (props.intent === "remove") {
|
if (props.intent === "remove") {
|
||||||
className += ` ${ style.remove}`;
|
className += ` ${ style.remove}`;
|
||||||
}
|
}
|
||||||
return <button className={className} {...props} />;
|
return <button type="button" className={className} {...props} />;
|
||||||
}
|
}
|
5
web/components/ButtonSet.module.scss
Normal file
5
web/components/ButtonSet.module.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.buttonSet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
9
web/components/ButtonSet.tsx
Normal file
9
web/components/ButtonSet.tsx
Normal 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;
|
@ -11,6 +11,7 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@ -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 {
|
.inputField {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@ -29,7 +11,13 @@
|
|||||||
max-width: calc(100% - 20px);
|
max-width: calc(100% - 20px);
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
color: var(--light-color);
|
color: var(--light-color);
|
||||||
background: #FFF;
|
background: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul > label {
|
||||||
|
position: relative;
|
||||||
|
left: auto;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
label.nopad {
|
label.nopad {
|
||||||
@ -46,10 +34,15 @@
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
color: var(--light-color);
|
color: var(--light-color);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.buttonSet {
|
select {
|
||||||
display: flex;
|
border: 1px solid #E3E8F0;
|
||||||
flex-direction: row;
|
box-sizing: border-box;
|
||||||
align-items: flex-start;
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--background-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--light-color);
|
||||||
|
}
|
||||||
}
|
}
|
17
web/components/InputField.tsx
Normal file
17
web/components/InputField.tsx
Normal 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;
|
@ -5,6 +5,7 @@ import BridgeAPI from "../BridgeAPI";
|
|||||||
import style from "./RoomConfigView.module.scss";
|
import style from "./RoomConfigView.module.scss";
|
||||||
import { ConnectionCard } from "./ConnectionCard";
|
import { ConnectionCard } from "./ConnectionCard";
|
||||||
import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig";
|
import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig";
|
||||||
|
import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig";
|
||||||
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@ -15,24 +16,31 @@ interface IProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RoomConfigView(props: IProps) {
|
export default function RoomConfigView(props: IProps) {
|
||||||
const [ activeConnectionType, setActiveConnectionType ] = useState<null|"generic">(null);
|
const [ activeConnectionType, setActiveConnectionType ] = useState<null|"generic"|"gitlab">(null);
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
if (activeConnectionType) {
|
if (activeConnectionType) {
|
||||||
content = <>
|
content = <>
|
||||||
{activeConnectionType === "generic" && <GenericWebhookConfig roomId={props.roomId} api={props.bridgeApi} />}
|
{activeConnectionType === "generic" && <GenericWebhookConfig roomId={props.roomId} api={props.bridgeApi} />}
|
||||||
|
{activeConnectionType === "gitlab" && <GitlabRepoConfig roomId={props.roomId} api={props.bridgeApi} />}
|
||||||
</>;
|
</>;
|
||||||
} else {
|
} else {
|
||||||
content = <>
|
content = <>
|
||||||
<section>
|
<section>
|
||||||
<h2> Integrations </h2>
|
<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
|
{props.supportedServices["generic"] && <ConnectionCard
|
||||||
imageSrc="./icons/webhook.png"
|
imageSrc="./icons/webhook.png"
|
||||||
serviceName="Generic Webhook"
|
serviceName="Generic Webhook"
|
||||||
description="Create a webhook which can be used to connect any service to Matrix"
|
description="Create a webhook which can be used to connect any service to Matrix"
|
||||||
onClick={() => setActiveConnectionType("generic")}
|
onClick={() => setActiveConnectionType("generic")}
|
||||||
>Setup Webhook Connection</ConnectionCard>}
|
/>}
|
||||||
</section>
|
</section>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { h, FunctionComponent, Fragment, createRef } from "preact";
|
import { h, FunctionComponent, createRef } from "preact";
|
||||||
import { useCallback, useState } from "preact/hooks"
|
import { useCallback, useState } from "preact/hooks"
|
||||||
import CodeMirror from '@uiw/react-codemirror';
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { ListItem } from "../ListItem";
|
|
||||||
import BridgeAPI from "../../BridgeAPI";
|
import BridgeAPI from "../../BridgeAPI";
|
||||||
import ErrorPane from "../ErrorPane";
|
|
||||||
import { GenericHookConnectionState, GenericHookResponseItem } from "../../../src/Connections/GenericHook";
|
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) {
|
const EXAMPLE_SCRIPT = `if (data.counter === undefined) {
|
||||||
result = {
|
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 DOCUMENTATION_LINK = "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html#script-api";
|
||||||
|
|
||||||
|
|
||||||
const ConnectionConfiguration: FunctionComponent<{
|
const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ServiceConfig, GenericHookResponseItem, GenericHookConnectionState>> = ({serviceConfig, existingConnection, onSave, onRemove}) => {
|
||||||
serviceConfig: ServiceConfig,
|
|
||||||
existingConnection?: GenericHookResponseItem,
|
|
||||||
onSave: (newConfig: GenericHookConnectionState) => void,
|
|
||||||
onRemove?: () => void
|
|
||||||
}> = ({serviceConfig, existingConnection, onSave, onRemove}) => {
|
|
||||||
|
|
||||||
const [transFn, setTransFn] = useState<string>(existingConnection?.config.transformationFunction as string || EXAMPLE_SCRIPT);
|
const [transFn, setTransFn] = useState<string>(existingConnection?.config.transformationFunction as string || EXAMPLE_SCRIPT);
|
||||||
const [transFnEnabled, setTransFnEnabled] = useState(serviceConfig.allowJsTransformationFunctions && !!existingConnection?.config.transformationFunction);
|
const [transFnEnabled, setTransFnEnabled] = useState(serviceConfig.allowJsTransformationFunctions && !!existingConnection?.config.transformationFunction);
|
||||||
const nameRef = createRef<HTMLInputElement>();
|
const nameRef = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
const onSaveClick = useCallback(() => {
|
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
|
||||||
|
const handleSave = useCallback((evt: Event) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (!canEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSave({
|
onSave({
|
||||||
name: nameRef?.current?.value || existingConnection?.config.name,
|
name: nameRef?.current?.value || existingConnection?.config.name,
|
||||||
...(transFnEnabled ? { transformationFunction: transFn } : undefined),
|
...(transFnEnabled ? { transformationFunction: transFn } : undefined),
|
||||||
});
|
});
|
||||||
if (!existingConnection) {
|
}, [canEdit, onSave, nameRef, transFn, existingConnection, transFnEnabled]);
|
||||||
// Clear fields
|
|
||||||
nameRef.current.value = "";
|
|
||||||
setTransFn(EXAMPLE_SCRIPT);
|
|
||||||
setTransFnEnabled(false);
|
|
||||||
}
|
|
||||||
}, [onSave, nameRef, transFn, setTransFnEnabled, setTransFn, existingConnection, transFnEnabled]);
|
|
||||||
|
|
||||||
const onRemoveClick = useCallback(() => {
|
return <form onSubmit={handleSave}>
|
||||||
onRemove();
|
<InputField visible={!existingConnection} label="Friendly name" noPadding={true}>
|
||||||
}, [onRemove]);
|
|
||||||
|
|
||||||
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
|
|
||||||
return <div>
|
|
||||||
{ !existingConnection && <div className={style.inputField}>
|
|
||||||
<label>Friendly name</label>
|
|
||||||
<input ref={nameRef} disabled={!canEdit} placeholder="My webhook" type="text" value={existingConnection?.config.name} />
|
<input ref={nameRef} disabled={!canEdit} placeholder="My webhook" type="text" value={existingConnection?.config.name} />
|
||||||
</div> }
|
</InputField>
|
||||||
|
|
||||||
{!!existingConnection && <div className={style.inputField}>
|
<InputField visible={!!existingConnection} label="URL" noPadding={true}>
|
||||||
<label>URL</label>
|
|
||||||
<input disabled={true} placeholder="URL hidden" type="text" value={existingConnection?.secrets?.url || ""} />
|
<input disabled={true} placeholder="URL hidden" type="text" value={existingConnection?.secrets?.url || ""} />
|
||||||
</div>}
|
</InputField>
|
||||||
|
|
||||||
{ serviceConfig.allowJsTransformationFunctions && <div className={style.inputField}>
|
<InputField visible={serviceConfig.allowJsTransformationFunctions} label="Enable Transformation JavaScript" noPadding={true}>
|
||||||
<label className={style.nopad}>Enable Transformation JavaScript</label>
|
<input disabled={!canEdit} type="checkbox" checked={transFnEnabled} onChange={useCallback(() => setTransFnEnabled(v => !v), [])} />
|
||||||
<input disabled={!canEdit} type="checkbox" checked={transFnEnabled} onChange={() => setTransFnEnabled(!transFnEnabled)} />
|
</InputField>
|
||||||
</div> }
|
|
||||||
|
|
||||||
{ transFnEnabled && <div className={style.inputField}>
|
<InputField visible={transFnEnabled} noPadding={true}>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
value={transFn}
|
value={transFn}
|
||||||
extensions={[javascript({ })]}
|
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>
|
<p> See the <a target="_blank" rel="noopener noreferrer" href={DOCUMENTATION_LINK}>documentation</a> for help writing transformation functions </p>
|
||||||
</div>}
|
</InputField>
|
||||||
|
<ButtonSet>
|
||||||
<div className={style.buttonSet}>
|
{ canEdit && <Button type="submit">{ existingConnection ? "Save" : "Add webhook" }</Button>}
|
||||||
{ canEdit && <Button onClick={onSaveClick}>{ existingConnection ? "Save" : "Add webhook"}</Button>}
|
{ canEdit && existingConnection && <Button intent="remove" onClick={onRemove}>Remove webhook</Button>}
|
||||||
{ canEdit && existingConnection && <Button intent="remove" onClick={onRemoveClick}>Remove webhook</Button>}
|
</ButtonSet>
|
||||||
</div>
|
</form>;
|
||||||
|
|
||||||
</div>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IGenericWebhookConfigProps {
|
interface IGenericWebhookConfigProps {
|
||||||
@ -102,85 +85,24 @@ interface ServiceConfig {
|
|||||||
allowJsTransformationFunctions: boolean
|
allowJsTransformationFunctions: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenericWebhookConfig: FunctionComponent<IGenericWebhookConfigProps> = ({ api, roomId }) => {
|
const RoomConfigText = {
|
||||||
const [ error, setError ] = useState<null|string>(null);
|
header: 'Generic Webhooks',
|
||||||
const [ connections, setConnections ] = useState<GenericHookResponseItem[]|null>(null);
|
createNew: 'Create new webhook',
|
||||||
const [ serviceConfig, setServiceConfig ] = useState<{allowJsTransformationFunctions: boolean}|null>(null);
|
listCanEdit: 'Your webhooks',
|
||||||
const [ canEditRoom, setCanEditRoom ] = useState<boolean>(false);
|
listCantEdit: 'Configured webhooks',
|
||||||
|
};
|
||||||
if (connections === null) {
|
|
||||||
api.getConnectionsForService<GenericHookResponseItem>(roomId, 'generic')
|
const RoomConfigListItemFunc = (c: GenericHookResponseItem) => c.config.name;
|
||||||
.then(res => {
|
|
||||||
setCanEditRoom(res.canEdit);
|
export const GenericWebhookConfig: FunctionComponent<IGenericWebhookConfigProps> = ({ api, roomId }) => {
|
||||||
setConnections(res.connections);
|
return <RoomConfig<ServiceConfig, GenericHookResponseItem, GenericHookConnectionState>
|
||||||
})
|
headerImg="./icons/webhook.png"
|
||||||
.catch(ex => {
|
api={api}
|
||||||
console.warn("Failed to fetch existing connections", ex);
|
roomId={roomId}
|
||||||
setError("Failed to fetch existing connections");
|
type="generic"
|
||||||
});
|
connectionEventType="uk.half-shot.matrix-hookshot.generic.hook"
|
||||||
}
|
text={RoomConfigText}
|
||||||
|
listItemName={RoomConfigListItemFunc}
|
||||||
if (serviceConfig === null) {
|
connectionConfigComponent={ConnectionConfiguration}
|
||||||
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>
|
|
||||||
</>;
|
|
||||||
};
|
};
|
205
web/components/roomConfig/GitlabRepoConfig.tsx
Normal file
205
web/components/roomConfig/GitlabRepoConfig.tsx
Normal 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}
|
||||||
|
/>;
|
||||||
|
};
|
17
web/components/roomConfig/RoomConfig.module.scss
Normal file
17
web/components/roomConfig/RoomConfig.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
119
web/components/roomConfig/RoomConfig.tsx
Normal file
119
web/components/roomConfig/RoomConfig.tsx
Normal 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
BIN
web/icons/gitlab.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
@ -5,6 +5,7 @@
|
|||||||
--foreground-color: #17191C;
|
--foreground-color: #17191C;
|
||||||
--light-color: #737D8C;
|
--light-color: #737D8C;
|
||||||
--primary-color: #0DBD8B;
|
--primary-color: #0DBD8B;
|
||||||
|
--primary-color-disabled: #0dbd8baf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @media (prefers-color-scheme: dark) {
|
// @media (prefers-color-scheme: dark) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user