mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 13:17:08 +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 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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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>;
|
||||
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -15,6 +15,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: #FF5B55;
|
||||
background-color: transparent;
|
||||
|
@ -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} />;
|
||||
}
|
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;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
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 { 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>
|
||||
</>;
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
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>
|
||||
</>;
|
||||
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}
|
||||
/>;
|
||||
};
|
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;
|
||||
--light-color: #737D8C;
|
||||
--primary-color: #0DBD8B;
|
||||
--primary-color-disabled: #0dbd8baf;
|
||||
}
|
||||
|
||||
// @media (prefers-color-scheme: dark) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user