Support Jira bridging

This commit is contained in:
Travis Ralston 2021-12-01 14:35:16 -07:00
parent e6f0563012
commit 2f3859aa11
14 changed files with 314 additions and 64 deletions

View File

@ -0,0 +1,103 @@
import { Context, DELETE, GET, Path, PathParam, POST, Security, ServiceContext } from "typescript-rest";
import { ApiError } from "../ApiError";
import { LogService } from "matrix-bot-sdk";
import { BridgedChannel, SlackBridge } from "../../bridges/SlackBridge";
import { SlackChannel, SlackTeam } from "../../bridges/models/slack";
import { ROLE_USER } from "../security/MatrixSecurity";
import { HookshotJiraBridge } from "../../bridges/HookshotJiraBridge";
import {
HookshotConnection,
HookshotJiraInstance,
HookshotJiraProject,
HookshotJiraRoomConfig
} from "../../bridges/models/hookshot";
interface BridgeRoomRequest {
instanceName: string;
projectKey: string;
}
/**
* API for interacting with the Hookshot/Jira bridge
*/
@Path("/api/v1/dimension/hookshot/jira")
export class DimensionHookshotJiraService {
@Context
private context: ServiceContext;
@GET
@Path("auth")
@Security(ROLE_USER)
public async getAuthUrl(): Promise<{ authUrl: string }> {
const userId = this.context.request.user.userId;
try {
const hookshot = new HookshotJiraBridge(userId);
const authUrl = await hookshot.getAuthUrl();
return {authUrl};
} catch (e) {
LogService.error("DimensionHookshotJiraService", e);
throw new ApiError(400, "Error getting auth info");
}
}
@GET
@Path("instances")
@Security(ROLE_USER)
public async getInstances(): Promise<{ instances: HookshotJiraInstance[] }> {
const userId = this.context.request.user.userId;
const hookshot = new HookshotJiraBridge(userId);
const userInfo = await hookshot.getLoggedInUserInfo();
if (!userInfo.loggedIn) {
throw new ApiError(403, "Not logged in", "T2B_NOT_LOGGED_IN");
}
return {instances: userInfo.instances};
}
@GET
@Path("instance/:instanceName/projects")
@Security(ROLE_USER)
public async getProjects(@PathParam("instanceName") instanceName: string): Promise<{ projects: HookshotJiraProject[] }> {
const userId = this.context.request.user.userId;
const hookshot = new HookshotJiraBridge(userId);
const projects = await hookshot.getProjects(instanceName);
return {projects};
}
@POST
@Path("room/:roomId/connect")
@Security(ROLE_USER)
public async bridgeRoom(@PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise<HookshotJiraRoomConfig> {
const userId = this.context.request.user.userId;
try {
const hookshot = new HookshotJiraBridge(userId);
return hookshot.bridgeRoom(roomId, request.instanceName, request.projectKey);
} catch (e) {
LogService.error("DimensionHookshotJiraService", e);
throw new ApiError(400, "Error bridging room");
}
}
@DELETE
@Path("room/:roomId/connections/all")
@Security(ROLE_USER)
public async unbridgeRoom(@PathParam("roomId") roomId: string): Promise<any> {
const userId = this.context.request.user.userId;
try {
const hookshot = new HookshotJiraBridge(userId);
const connections = await hookshot.getRoomConfigurations(roomId);
for (const conn of connections) {
await hookshot.unbridgeRoom(roomId, conn.id);
}
return {}; // 200 OK
} catch (e) {
LogService.error("DimensionHookshotJiraService", e);
throw new ApiError(400, "Error unbridging room");
}
}
}

View File

@ -52,6 +52,7 @@ export class DimensionSlackService {
@DELETE
@Path("room/:roomId/link")
@Security(ROLE_USER)
public async unbridgeRoom(@PathParam("roomId") roomId: string): Promise<any> {
const userId = this.context.request.user.userId;

View File

@ -1,5 +1,11 @@
import HookshotJiraBridgeRecord from "../db/models/HookshotJiraBridgeRecord";
import { HookshotConnection, HookshotGithubRoomConfig, HookshotJiraRoomConfig, HookshotTypes } from "./models/hookshot";
import {
HookshotConnection,
HookshotGithubRoomConfig, HookshotJiraProject,
HookshotJiraRoomConfig,
HookshotJiraUserInfo,
HookshotTypes
} from "./models/hookshot";
import { HookshotBridge } from "./HookshotBridge";
export class HookshotJiraBridge extends HookshotBridge {
@ -16,6 +22,21 @@ export class HookshotJiraBridge extends HookshotBridge {
return bridges[0];
}
public async getAuthUrl(): Promise<string> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest(bridge, "GET", `/v1/jira/oauth`).then(r => r['url']);
}
public async getLoggedInUserInfo(): Promise<HookshotJiraUserInfo> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest<HookshotJiraUserInfo>(bridge, "GET", `/v1/jira/account`);
}
public async getProjects(instanceName: string): Promise<HookshotJiraProject[]> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest<HookshotJiraProject[]>(bridge, "GET", `/v1/jira/instances/${instanceName}/projects`);
}
public async getBotUserId(): Promise<string> {
const confs = await this.getAllServiceInformation();
const conf = confs.find(c => c.eventType === HookshotTypes.Jira);
@ -27,19 +48,27 @@ export class HookshotJiraBridge extends HookshotBridge {
return !!bridges && bridges.length > 0 && !!(await this.getBotUserId());
}
public async getRoomConfigurations(inRoomId: string): Promise<HookshotGithubRoomConfig[]> {
return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Jira);
public async getRoomConfigurations(inRoomId: string): Promise<HookshotJiraRoomConfig[]> {
return (await this.getAllRoomConfigurations(inRoomId))
.filter(c => c.eventType === HookshotTypes.Jira);
}
public async bridgeRoom(roomId: string): Promise<HookshotJiraRoomConfig> {
public async bridgeRoom(roomId: string, instanceName: string, projectKey: string): Promise<HookshotJiraRoomConfig> {
const bridge = await this.getDefaultBridge();
const body = {};
const projects = await this.getProjects(instanceName);
const project = projects.find(p => p.key === projectKey);
if (!project) throw new Error("Could not find project");
const body = {
url: project.url,
commandPrefix: "!jira",
};
return await this.doProvisionRequest<HookshotJiraRoomConfig>(bridge, "PUT", `/v1/${roomId}/connections/${HookshotTypes.Jira}`, null, body);
}
public async unbridgeRoom(roomId: string, connectionId: string): Promise<void> {
const bridge = await this.getDefaultBridge();
await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${connectionId}`);
await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${encodeURIComponent(connectionId)}`);
}
}

View File

@ -24,14 +24,31 @@ export enum SupportedJiraEventType {
IssueCreated = "issue.created",
}
export interface HookshotJiraRoomConfig {
id: string;
url: string;
events: SupportedJiraEventType[];
commandPrefix: string;
export interface HookshotJiraRoomConfig extends HookshotConnection {
config: {
url: string;
events: SupportedJiraEventType[];
commandPrefix: string;
};
}
export enum HookshotTypes {
Github = "uk.half-shot.matrix-hookshot.github.repository",
Jira = "uk.half-shot.matrix-hookshot.jira.project",
}
export interface HookshotJiraUserInfo {
loggedIn: boolean;
instances?: HookshotJiraInstance[];
}
export interface HookshotJiraInstance {
name: string;
url: string;
}
export interface HookshotJiraProject {
key: string;
name: string;
url: string;
}

View File

@ -161,9 +161,12 @@ export class BridgeStore {
const hookshot = new HookshotJiraBridge(requestingUserId);
const botUserId = await hookshot.getBotUserId();
const connections = await hookshot.getRoomConfigurations(inRoomId);
const userInfo = await hookshot.getLoggedInUserInfo();
return <HookshotJiraBridgeConfiguration>{
botUserId: botUserId,
connections: connections,
loggedIn: userInfo.loggedIn,
instances: userInfo.instances,
};
} else return {};
}

View File

@ -21,7 +21,7 @@ LogService.info("index", "Starting dimension " + CURRENT_VERSION);
async function startup() {
const schemas = await DimensionStore.updateSchema();
LogService.info("DimensionStore", schemas);
LogService.info("DimensionStore", "Applied schemas: ", schemas);
const webserver = new Webserver();
await webserver.start();

View File

@ -4,7 +4,7 @@ import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge";
import { PortalInfo, PuppetInfo } from "../bridges/TelegramBridge";
import { WebhookConfiguration } from "../bridges/models/webhooks";
import { BridgedChannel } from "../bridges/SlackBridge";
import { HookshotConnection } from "../bridges/models/hookshot";
import { HookshotConnection, HookshotJiraInstance } from "../bridges/models/hookshot";
const PRIVATE_ACCESS_SUPPORTED_BRIDGES = ["webhooks"];
@ -55,4 +55,6 @@ export interface HookshotGithubBridgeConfiguration {
export interface HookshotJiraBridgeConfiguration {
botUserId: string;
connections: HookshotConnection[];
loggedIn: boolean;
instances?: HookshotJiraInstance[];
}

View File

@ -8,29 +8,35 @@
<my-spinner></my-spinner>
</div>
<div class="my-ibox-content" *ngIf="!loadingConnections">
<div *ngIf="!isBridged && needsAuth">
<div *ngIf="isBridged">
<p>{{'This room is bridged to' | translate}} <a [href]="bridgedProjectUrl" rel="noopener" target="_blank">{{bridgedProjectUrlUnsafe}}</a></p>
<button type="button" class="btn btn-sm btn-danger" [disabled]="isBusy" (click)="unbridgeRoom()">
{{'Unbridge' | translate}}
</button>
</div>
<div *ngIf="!isBridged && authUrl">
<p>
{{'In order to bridge to Jira, you\'ll need to authorize the bridge to access your organization(s). Please click the button below to do so.' | translate}}
</p>
<a [href]="authUrl" rel="noopener" target="_blank">
<img src="/assets/img/slack_auth_button.png" alt="sign in with slack"/>
<a [href]="authUrl" rel="noopener" target="_blank" class="btn btn-lg btn-link">
<img src="/assets/img/avatars/jira.png" width="35" /> {{'Sign in with Jira' | translate}}
</a>
</div>
<div *ngIf="!isBridged && !needsAuth">
<div *ngIf="!isBridged && !authUrl">
<label class="label-block">
{{'Organization' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="orgId"
(change)="loadRepos()" [disabled]="isBusy">
<option *ngFor="let org of orgs" [ngValue]="org">
{{ org }}
{{'Instance / Organization' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="instance"
(change)="loadProjects()" [disabled]="isBusy">
<option *ngFor="let instance of instances" [ngValue]="instance">
{{ instance.name }} ({{ instance.url }})
</option>
</select>
</label>
<label class="label-block">
{{'Repository' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="repoId" [disabled]="isBusy">
<option *ngFor="let repo of repos" [ngValue]="repo">
{{ repo }}
{{'Project' | translate}}
<select class="form-control form-control-sm" [(ngModel)]="project" [disabled]="isBusy">
<option *ngFor="let project of projects" [ngValue]="project">
{{ project.key }} ({{ project.name }})
</option>
</select>
</label>

View File

@ -1,14 +1,20 @@
import { Component, OnInit } from "@angular/core";
import { BridgeComponent } from "../bridge.component";
import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service";
import { SafeUrl } from "@angular/platform-browser";
import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
import { TranslateService } from "@ngx-translate/core";
import { HookshotJiraApiService } from "../../../shared/services/integrations/hookshot-jira-api.service";
import { FE_HookshotJiraConnection } from "../../../shared/models/hookshot_jira";
import {
FE_HookshotJiraConnection,
FE_HookshotJiraInstance,
FE_HookshotJiraProject
} from "../../../shared/models/hookshot_jira";
interface HookshotConfig {
botUserId: string;
connections: FE_HookshotJiraConnection[];
loggedIn: boolean;
instances?: FE_HookshotJiraInstance[];
}
@Component({
@ -18,31 +24,76 @@ interface HookshotConfig {
export class HookshotJiraBridgeConfigComponent extends BridgeComponent<HookshotConfig> implements OnInit {
public isBusy: boolean;
public needsAuth = false;
public authUrl: SafeUrl;
public loadingConnections = false;
public orgs: string[] = [];
public repos: string[] = []; // for org
public orgId: string;
public repoId: string;
public loadingConnections = true;
public bridgedProjectUrl: SafeUrl;
public bridgedProjectUrlUnsafe: string;
constructor(private hookshot: HookshotJiraApiService, private scalar: ScalarClientApiService, public translate: TranslateService) {
public instances: FE_HookshotJiraInstance[] = [];
public instance: FE_HookshotJiraInstance;
public projects: FE_HookshotJiraProject[] = [];
public project: FE_HookshotJiraProject;
private timerId: any;
constructor(private hookshot: HookshotJiraApiService, private scalar: ScalarClientApiService, private sanitizer: DomSanitizer, public translate: TranslateService) {
super("hookshot_jira", translate);
this.translate = translate;
}
public ngOnInit() {
super.ngOnInit();
this.prepare();
this.tryLoadInstances();
}
private prepare() {
private tryLoadInstances() {
this.loadingConnections = true;
this.hookshot.getInstances().then(r => {
this.instances = r;
this.instance = this.instances[0];
this.loadProjects();
if (this.timerId) {
clearInterval(this.timerId);
}
}).catch(e => {
if (e.status === 403 && e.error.dim_errcode === "T2B_NOT_LOGGED_IN") {
this.hookshot.getAuthUrl().then(url => {
this.authUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url);
this.loadingConnections = false;
this.timerId = setInterval(() => {
this.tryLoadInstances();
}, 1000);
});
} else {
console.error(e);
this.translate.get('Error getting Jira information').subscribe((res: string) => {
this.toaster.pop("error", res);
});
}
});
}
public loadRepos() {
// TODO
public loadProjects() {
this.isBusy = true;
this.hookshot.getProjects(this.instance.name).then(projects => {
this.projects = projects;
this.project = this.projects[0];
if (this.isBridged) {
this.bridgedProjectUrlUnsafe = this.bridge.config.connections[0].config.url;
this.bridgedProjectUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.bridgedProjectUrlUnsafe);
}
this.isBusy = false;
this.loadingConnections = false;
}).catch(e => {
console.error(e);
this.isBusy = false;
this.translate.get('Error getting Jira information').subscribe((res: string) => {
this.toaster.pop("error", res);
});
});
}
public get isBridged(): boolean {
@ -65,7 +116,9 @@ export class HookshotJiraBridgeConfigComponent extends BridgeComponent<HookshotC
}
}
this.hookshot.bridgeRoom(this.roomId).then(conn => {
await this.scalar.setUserPowerLevel(this.roomId, this.bridge.config.botUserId, 50);
this.hookshot.bridgeRoom(this.roomId, this.instance.name, this.project.key).then(conn => {
this.bridge.config.connections.push(conn);
this.isBusy = false;
this.translate.get('Bridge requested').subscribe((res: string) => {

View File

@ -7,5 +7,19 @@ export interface FE_HookshotJiraBridge {
}
export interface FE_HookshotJiraConnection {
config: {
url: string;
commandPrefix?: string;
};
}
export interface FE_HookshotJiraInstance {
name: string;
url: string;
}
export interface FE_HookshotJiraProject {
key: string;
name: string;
url: string;
}

View File

@ -1,7 +1,7 @@
import { Injectable } from "@angular/core";
import { AuthedApi } from "../authed-api";
import { HttpClient } from "@angular/common/http";
import { FE_HookshotJiraConnection } from "../../models/hookshot_jira";
import { FE_HookshotJiraConnection, FE_HookshotJiraInstance, FE_HookshotJiraProject } from "../../models/hookshot_jira";
@Injectable()
export class HookshotJiraApiService extends AuthedApi {
@ -9,13 +9,26 @@ export class HookshotJiraApiService extends AuthedApi {
super(http);
}
public bridgeRoom(roomId: string): Promise<FE_HookshotJiraConnection> {
public getProjects(instanceName: string): Promise<FE_HookshotJiraProject[]> {
return this.authedGet("/api/v1/dimension/hookshot/jira/instance/" + instanceName + "/projects").toPromise().then(r => r['projects']);
}
public getInstances(): Promise<FE_HookshotJiraInstance[]> {
return this.authedGet("/api/v1/dimension/hookshot/jira/instances").toPromise().then(r => r['instances']);
}
public getAuthUrl(): Promise<string> {
return this.authedGet("/api/v1/dimension/hookshot/jira/auth").toPromise().then(r => r['authUrl']);
}
public bridgeRoom(roomId: string, instanceName: string, projectKey: string): Promise<FE_HookshotJiraConnection> {
return this.authedPost<FE_HookshotJiraConnection>("/api/v1/dimension/hookshot/jira/room/" + roomId + "/connect", {
// TODO
instanceName: instanceName,
projectKey: projectKey,
}).toPromise();
}
public unbridgeRoom(roomId: string): Promise<any> {
return this.authedDelete("/api/v1/dimension/hookshot/jira/" + roomId + "/connections/all").toPromise();
return this.authedDelete("/api/v1/dimension/hookshot/jira/room/" + roomId + "/connections/all").toPromise();
}
}

View File

@ -158,6 +158,9 @@
"Repository": "Repository",
"Bridge to Jira": "Bridge to Jira",
"In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.": "In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.",
"Sign in with Jira": "Sign in with Jira",
"Instance / Organization": "Instance / Organization",
"Project": "Project",
"Add an IRC channel": "Add an IRC channel",
"Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.": "Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.",
"Channel Name": "Channel Name",
@ -243,6 +246,14 @@
"Whiteboard Name": "Whiteboard Name",
"Whiteboard URL": "Whiteboard URL",
"Video URL": "Video URL",
"Looking for your sticker packs?": "Looking for your sticker packs?",
"Click here": "Click here",
"This room is encrypted": "This room is encrypted",
"Integrations are not encrypted!": "Integrations are not encrypted!",
"This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.": "This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.",
"There are currently no integrations which support encrypted rooms. Sorry about that!": "There are currently no integrations which support encrypted rooms. Sorry about that!",
"No integrations available": "No integrations available",
"This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.",
"An open source integration manager for Matrix": "An open source integration manager for Matrix",
"Self-host your favourite bots, bridges, and widgets.": "Self-host your favourite bots, bridges, and widgets.",
"source": "source",
@ -294,14 +305,6 @@
"Using tools like the": "Using tools like the",
"federation tester": "federation tester",
", make sure that federation is working on your homeserver.": ", make sure that federation is working on your homeserver.",
"Looking for your sticker packs?": "Looking for your sticker packs?",
"Click here": "Click here",
"This room is encrypted": "This room is encrypted",
"Integrations are not encrypted!": "Integrations are not encrypted!",
"This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.": "This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.",
"There are currently no integrations which support encrypted rooms. Sorry about that!": "There are currently no integrations which support encrypted rooms. Sorry about that!",
"No integrations available": "No integrations available",
"This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.",
"BigBlueButton Conference": "BigBlueButton Conference",
"Join Conference": "Join Conference",
"Sorry, this content cannot be embedded": "Sorry, this content cannot be embedded",
@ -410,6 +413,7 @@
"Error requesting bridge": "Error requesting bridge",
"Bridge removed": "Bridge removed",
"Error removing bridge": "Error removing bridge",
"Error getting Jira information": "Error getting Jira information",
"Please enter a channel name": "Please enter a channel name",
"Error loading channel operators": "Error loading channel operators",
"Failed to make the bridge an administrator": "Failed to make the bridge an administrator",

View File

@ -158,6 +158,9 @@
"Repository": "Repository",
"Bridge to Jira": "Bridge to Jira",
"In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.": "In order to bridge to Jira, you'll need to authorize the bridge to access your organization(s). Please click the button below to do so.",
"Sign in with Jira": "Sign in with Jira",
"Instance / Organization": "Instance / Organization",
"Project": "Project",
"Add an IRC channel": "Add an IRC channel",
"Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.": "Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will join the channel to ensure it exists and has operators available.",
"Channel Name": "Channel Name",
@ -243,6 +246,14 @@
"Whiteboard Name": "Whiteboard Name",
"Whiteboard URL": "Whiteboard URL",
"Video URL": "Video URL",
"Looking for your sticker packs?": "Looking for your sticker packs?",
"Click here": "Click here",
"This room is encrypted": "This room is encrypted",
"Integrations are not encrypted!": "Integrations are not encrypted!",
"This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.": "This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.",
"There are currently no integrations which support encrypted rooms. Sorry about that!": "There are currently no integrations which support encrypted rooms. Sorry about that!",
"No integrations available": "No integrations available",
"This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.",
"An open source integration manager for Matrix": "An open source integration manager for Matrix",
"Self-host your favourite bots, bridges, and widgets.": "Self-host your favourite bots, bridges, and widgets.",
"source": "source",
@ -294,14 +305,6 @@
"Using tools like the": "Using tools like the",
"federation tester": "federation tester",
", make sure that federation is working on your homeserver.": ", make sure that federation is working on your homeserver.",
"Looking for your sticker packs?": "Looking for your sticker packs?",
"Click here": "Click here",
"This room is encrypted": "This room is encrypted",
"Integrations are not encrypted!": "Integrations are not encrypted!",
"This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.": "This means that some information about yourself and the room may be leaked to the bot, bridge, or widget. This information includes the room ID, your display name, your username, your avatar, information about Element, and other similar details. Add integrations with caution.",
"There are currently no integrations which support encrypted rooms. Sorry about that!": "There are currently no integrations which support encrypted rooms. Sorry about that!",
"No integrations available": "No integrations available",
"This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.",
"BigBlueButton Conference": "BigBlueButton Conference",
"Join Conference": "Join Conference",
"Sorry, this content cannot be embedded": "Sorry, this content cannot be embedded",
@ -410,6 +413,7 @@
"Error requesting bridge": "Error requesting bridge",
"Bridge removed": "Bridge removed",
"Error removing bridge": "Error removing bridge",
"Error getting Jira information": "Error getting Jira information",
"Please enter a channel name": "Please enter a channel name",
"Error loading channel operators": "Error loading channel operators",
"Failed to make the bridge an administrator": "Failed to make the bridge an administrator",

View File

@ -4,6 +4,7 @@
.main-app {
a {
color: themed(anchorColor);
text-decoration: none;
}
table, td, th {
@ -32,4 +33,4 @@
display: flex;
}
}
}
}