Webapp - Outgoing OAuth Connections (#25507)

* added store

* make generated

* add missing license headers

* fix receiver name

* i18n

* i18n sorting

* update migrations from master

* make migrations-extract

* update retrylayer tests

* replaced sql query with id pagination

* fixed flaky tests

* missing columns

* missing columns on save/update

* typo

* improved tests

* remove enum from mysql colum

* add password credentials to store

* license changes

* OAuthOutgoingConnectionInterface

* Oauth -> OAuth

* make generated

* copied over installed_oauth_apps component and renamed things to installed_outgoing_oauth_connections

* merge migrations

* renamed migrations

* model change suggestions

* refactor test functionsn

* migration typo

* refactor store table names

* updated sanitize test

* cleanup merge

* refactor symbol

* "installed outgoing oauth connections" page works

* move things into a nested folder

* add and edit page stubs work

* list endpoint

* oauthoutgoingconnection -> outgoingoauthconnection

* signature change

* i18n update

* granttype typo

* naming

* api list

* uppercase typo

* i18n

* missing license header

* fixed path in comments

* updated openapi definitions

* changes to support selecting command request url

* sanitize connections

* make generated

* test license and no feature flag

* removed t.fatal

* updated testhelper calls

* yaml schema fixes

* switched interface name

* suggested translation

* missing i18n translation

* management permission

* moved permission initalization to proper place

* endpoints

* put tests

* error check typo

* fixed specific enttity urls

* tests

* read permission check

* updated openapi definitions

* i18n

* GetConnectionByAudience method

* notes

* replaced GetConnectionsByAudience with a filter

* added custom oauth token object

* updated interface and usage

* properly set enterprise interface

* move retrieval logic to impl

* webhook tests

* translations

* i18n: updates

* address comments

* endpoint and tests

* i18n

* api docs

* fixed endpoint path

* sq.like

* use filter object instead of parameters

* set url values if not empty

* typos

* converted some components to function components, and move around files

* correctly check token url

* restore flag to previous value

* added command oauth handler

* update enterprise imports

* migrate last component to function component

* Added enterprise import

* refactor permissions and add necessary webapp code

* Check correct flag in permission tree

* allow partial updates

* sort i18n webapp

* missing test modification

* fixed webapp i18n sorting

* allow validating stored connections

* added missing translation

* fix finished adding connection link and text on result page

* added missing permission to smoke tests

* missing role in smoke test

* updated translations

* updated translations

* support editing client secret on existing connection

* fix some i18n strings

* updated translations

* better error messages

* progress on using react select for command request url while maintaining typed in value

* remove writeheader, test

* HasValidGrantType

* end early to avoid nil pointer errors

* move slash command request url input box into its own component

* wrap components related to oauth connections in config check

* fix tests

* i18n-extract

* change some i18n strings to say "Outgoing OAuth 2.0 Connections"

* remove debug code

* fixed i18n

* updated i18n file

* feature configuration backend

* typo

* add system console setting

* Revert "typo"

This reverts commit 669da23e8ee47525ccaa6f59cbbd20bf8a121191.

* Revert "updated i18n file"

This reverts commit d0882c0dd7587533f0d0f7a7b7b190684186158a.

* Revert "fixed i18n"

This reverts commit 3108866bc19139182dfd094921c56cdefc4695ea.

* fixed i18n

* updated i18n file

* typo

* updated i18n

* updated i18n

* updated i18n

* updated version to 9.6

* replace feature flag with system console configuration

* i18n

* updated tests

* pr feedback

* fix styling of disabled text box

* fix styling of action links in integration console

* server changes for validation feature

* webapp changes for validation feature

* pencil icon styling

* styling fixes for oauth audience correct configuration message

* fix sanitize test

* remove max lengths from outgoing oauth connection form

* use config var in webapp instead of feature flag

* change asterisks to bullets

* update api docs for validate endpoint

* feedback from ux review

* fix lint, types, tests

* fix stylelint

* implement validation button under the token url input

* support wildcard for matching audience urls

* updates for styling

* update snapshots

* add doc links for the outgoing oauth connections feature

* change doc links to use permalink

* add docs link to system console

* fix: use limitedreader in json decoding

* fix: form error in validation

* management permission can read now

* updated api documentation

* doc typo

* require one permission to read only

* fix api connection list audience filter

* fix audience matching and add loading indicator

* fix team permissions on outgoing oauth connection api calls

* fix api doc and test, for adding team id to query params

* handle read permissions by adding a team in the payload

* missing teamid query parameter in test

* change validate button logic to not require audience urls to be filled out

* fix redux type

---------

Co-authored-by: Felipe Martin <me@fmartingr.com>
Этот коммит содержится в:
Michael Kochell
2024-02-09 14:49:49 -05:00
коммит произвёл GitHub
родитель 3f6c94cfc3
Коммит 4e071e861c
91 изменённых файлов: 10116 добавлений и 201 удалений

Просмотреть файл

@@ -169,13 +169,9 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="url"
maxLength={1024}
<OAuthConnectionAudienceInput
onChange={[Function]}
placeholder="Must start with http:// or https://"
type="text"
value="https://google.com/command"
/>
<div
@@ -186,6 +182,19 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
id="add_command.url.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="You can connect commands to <link>outgoing OAuth connections</link>."
id="add_command.outgoing_oauth_connections.help_text"
values={
Object {
"link": [Function],
}
}
/>
</div>
</div>
</div>
<div
@@ -609,13 +618,9 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="url"
maxLength={1024}
<OAuthConnectionAudienceInput
onChange={[Function]}
placeholder="Must start with http:// or https://"
type="text"
value="https://google.com/command"
/>
<div
@@ -626,6 +631,19 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
id="add_command.url.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="You can connect commands to <link>outgoing OAuth connections</link>."
id="add_command.outgoing_oauth_connections.help_text"
values={
Object {
"link": [Function],
}
}
/>
</div>
</div>
</div>
<div
@@ -1049,13 +1067,9 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="url"
maxLength={1024}
<OAuthConnectionAudienceInput
onChange={[Function]}
placeholder="Must start with http:// or https://"
type="text"
value="https://google.com/command"
/>
<div
@@ -1066,6 +1080,19 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
id="add_command.url.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="You can connect commands to <link>outgoing OAuth connections</link>."
id="add_command.outgoing_oauth_connections.help_text"
values={
Object {
"link": [Function],
}
}
/>
</div>
</div>
</div>
<div

Просмотреть файл

@@ -10,18 +10,18 @@ exports[`components/integrations/InstalledCommand should call onDelete function
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
<div
className="item-actions"
>
@@ -116,18 +116,18 @@ exports[`components/integrations/InstalledCommand should call onRegenToken funct
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
<div
className="item-actions"
>
@@ -224,18 +224,18 @@ exports[`components/integrations/InstalledCommand should match snapshot 1`] = `
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<div
className="item-details__row"
@@ -298,18 +298,18 @@ exports[`components/integrations/InstalledCommand should match snapshot, not aut
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
command_display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger
</span>
</div>
<div
className="item-details__row"

Просмотреть файл

@@ -17,6 +17,8 @@ import SpinnerButton from 'components/spinner_button';
import {Constants, DeveloperLinks} from 'utils/constants';
import * as Utils from 'utils/utils';
import OAuthConnectionAudienceInput from './outgoing_oauth_connections/oauth_connection_audience_input';
const REQUEST_POST = 'P';
const REQUEST_GET = 'G';
@@ -65,7 +67,7 @@ type Props = {
intl: IntlShape;
}
type State= {
type State = {
saving: boolean;
clientError: null | JSX.Element | string;
trigger: string;
@@ -87,7 +89,7 @@ export class AbstractCommand extends React.PureComponent<Props, State> {
this.state = this.getStateFromCommand(this.props.initialCommand || {});
}
getStateFromCommand = (command: Props['initialCommand']) => {
getStateFromCommand = (command: Props['initialCommand']): State => {
return {
displayName: command?.display_name ?? '',
description: command?.description ?? '',
@@ -523,11 +525,7 @@ export class AbstractCommand extends React.PureComponent<Props, State> {
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='url'
type='text'
maxLength={1024}
className='form-control'
<OAuthConnectionAudienceInput
value={this.state.url}
onChange={this.updateUrl}
placeholder={this.props.intl.formatMessage({
@@ -541,6 +539,17 @@ export class AbstractCommand extends React.PureComponent<Props, State> {
defaultMessage='Specify the callback URL to receive the HTTP POST or GET event request when the slash command is run.'
/>
</div>
<div className='form__help'>
<FormattedMessage
id={'add_command.outgoing_oauth_connections.help_text'}
defaultMessage={'You can connect commands to <link>outgoing OAuth connections</link>.'}
values={{
link: (text: string) => (
<a href='https://mattermost.com/pl/outgoing-oauth-connections'>{text}</a>
),
}}
/>
</div>
</div>
</div>
<div className='form-group'>

Просмотреть файл

@@ -148,7 +148,6 @@ describe('components/integrations/AbstractOutgoingWebhook', () => {
const selector = wrapper.find('#triggerWhen');
selector.simulate('change', {target: {value: 1}});
console.log('selector: ', selector.debug());
expect(wrapper.state('triggerWhen')).toBe(1);
});

Просмотреть файл

@@ -522,6 +522,7 @@ export default class Bot extends React.PureComponent<Props, State> {
defaultMessage='Delete'
/>
}
modalClass='integrations-backstage-modal'
show={this.state.confirmingId !== ''}
onConfirm={this.revokeTokenConfirmed}
onCancel={this.closeConfirm}

Просмотреть файл

@@ -347,3 +347,114 @@ exports[`components/integrations/ConfirmIntegration should match snapshot, outgo
</div>
</div>
`;
exports[`components/integrations/ConfirmIntegration should match snapshot, outgoingOAuthConnections case 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/team_test/integrations/outgoing-oauth2-connections"
>
<MemoizedFormattedMessage
defaultMessage="Outgoing OAuth 2.0 Connections"
id="installed_outgoing_oauth_connections.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Add"
id="integrations.add"
/>
</BackstageHeader>
<div
className="backstage-form backstage-form__confirmation"
>
<h4
className="backstage-form__title"
id="formTitle"
>
<MemoizedFormattedMessage
defaultMessage="Setup Successful"
id="integrations.successful"
/>
</h4>
<p
key="add_outgoing_oauth_connection.doneHelp"
>
<MemoizedFormattedMessage
defaultMessage="Your Outgoing OAuth 2.0 Connection is set up. When a request is sent to one of the following Audience URLs, the Client ID and Client Secret will now be used to retrieve a token from the Token URL, before sending the integration request (details at <link>Outgoing OAuth 2.0 Connections</link>)."
id="add_outgoing_oauth_connection.doneHelp"
values={
Object {
"link": [Function],
}
}
/>
</p>
<p
key="add_outgoing_oauth_connection.clientId"
>
<FormattedMarkdownMessage
defaultMessage="**Client ID**: {id}"
id="add_outgoing_oauth_connection.clientId"
values={
Object {
"id": "someid",
}
}
/>
<br />
<FormattedMarkdownMessage
defaultMessage="**Client Secret**: \\\\*\\\\*\\\\*\\\\*\\\\*\\\\*\\\\*\\\\*"
id="add_outgoing_oauth_connection.clientSecret"
values={
Object {
"secret": "",
}
}
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**Token URL**: \`{url}\`"
id="add_outgoing_oauth_connection.token_url"
values={
Object {
"url": "https://tokenurl.com",
}
}
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**Audience URL(s)**: \`{url}\`"
id="add_outgoing_oauth_connection.audience_urls"
values={
Object {
"url": "https://myaudience.com",
}
}
/>
</p>
<div
className="backstage-form__footer"
>
<Link
className="btn btn-primary"
id="doneButton"
to="/team_test/integrations/outgoing-oauth2-connections"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Done"
id="integrations.done"
/>
</Link>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -5,7 +5,7 @@ import {shallow} from 'enzyme';
import React from 'react';
import type {Bot} from '@mattermost/types/bots';
import type {IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations';
import type {IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook} from '@mattermost/types/integrations';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import ConfirmIntegration from 'components/integrations/confirm_integration/confirm_integration';
@@ -43,6 +43,16 @@ describe('components/integrations/ConfirmIntegration', () => {
client_secret: '<==secret==>',
callback_urls: ['https://someCallback', 'https://anotherCallback'],
};
const outgoingOAuthConnection = {
id,
audiences: ['https://myaudience.com'],
client_id: 'someid',
client_secret: '',
grant_type: 'client_credentials',
name: 'My OAuth Connection',
oauth_token_url: 'https://tokenurl.com',
};
const userId = 'b5tpgt4iepf45jt768jz84djhd';
const bot = TestHelper.getBotMock({
user_id: userId,
@@ -50,6 +60,7 @@ describe('components/integrations/ConfirmIntegration', () => {
});
const commands = {[id]: TestHelper.getCommandMock({id, token})};
const oauthApps = {[id]: oauthApp} as unknown as IDMappedObjects<OAuthApp>;
const outgoingOAuthConnections = {[id]: outgoingOAuthConnection} as unknown as IDMappedObjects<OutgoingOAuthConnection>;
const incomingHooks: IDMappedObjects<IncomingWebhook> = {[id]: TestHelper.getIncomingWebhookMock({id})};
const outgoingHooks = {[id]: {id, token}} as unknown as IDMappedObjects<OutgoingWebhook>;
const bots: Record<string, Bot> = {[userId]: bot};
@@ -59,6 +70,7 @@ describe('components/integrations/ConfirmIntegration', () => {
location,
commands,
oauthApps,
outgoingOAuthConnections,
incomingHooks,
outgoingHooks,
bots,
@@ -82,6 +94,14 @@ describe('components/integrations/ConfirmIntegration', () => {
expect(container.querySelector('.word-break--all')).toHaveTextContent('URL(s): https://someCallback, https://anotherCallback');
});
test('should match snapshot, outgoingOAuthConnections case', () => {
props.location.search = getSearchString('outgoing-oauth2-connections');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, commands case', () => {
props.location.search = getSearchString('commands');
const wrapper = shallow(

Просмотреть файл

@@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl';
import {Link, useHistory} from 'react-router-dom';
import type {Bot} from '@mattermost/types/bots';
import type {Command, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations';
import type {Command, IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
@@ -26,9 +26,10 @@ type Props = {
incomingHooks: IDMappedObjects<IncomingWebhook>;
outgoingHooks: IDMappedObjects<OutgoingWebhook>;
bots: Record<string, Bot>;
outgoingOAuthConnections: Record<string, OutgoingOAuthConnection>;
}
const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, outgoingHooks, bots}: Props): JSX.Element | null => {
const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, outgoingHooks, bots, outgoingOAuthConnections}: Props): JSX.Element | null => {
const history = useHistory();
const type = (new URLSearchParams(location.search)).get('type') || '';
@@ -56,6 +57,7 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks,
const incomingHook = incomingHooks[id];
const outgoingHook = outgoingHooks[id];
const oauthApp = oauthApps[id];
const outgoingOAuthConnection = outgoingOAuthConnections[id];
const bot = bots[id];
if (type === Constants.Integrations.COMMAND && command) {
@@ -243,6 +245,95 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks,
/>
</p>
);
} else if (type === Constants.Integrations.OUTGOING_OAUTH_CONNECTIONS && outgoingOAuthConnection) {
const clientId = outgoingOAuthConnection.client_id;
const clientSecret = outgoingOAuthConnection.client_secret;
const username = outgoingOAuthConnection.credentials_username;
const password = outgoingOAuthConnection.credentials_password;
headerText = (
<FormattedMessage
id='installed_outgoing_oauth_connections.header'
defaultMessage='Outgoing OAuth 2.0 Connections'
/>
);
helpText = [];
helpText.push(
<p key='add_outgoing_oauth_connection.doneHelp'>
<FormattedMessage
id='add_outgoing_oauth_connection.doneHelp'
defaultMessage='Your Outgoing OAuth 2.0 Connection is set up. When a request is sent to one of the following Audience URLs, the Client ID and Client Secret will now be used to retrieve a token from the Token URL, before sending the integration request (details at <link>Outgoing OAuth 2.0 Connections</link>).'
values={{
link: (msg: string) => (
<ExternalLink
href={DeveloperLinks.SETUP_OAUTH2} // TODO: dev docs for outgoing oauth connections feature
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>,
);
helpText.push(
<p key='add_outgoing_oauth_connection.clientId'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.clientId'
defaultMessage='**Client ID**: {id}'
values={{id: clientId}}
/>
<br/>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.clientSecret'
defaultMessage='**Client Secret**: \*\*\*\*\*\*\*\*'
values={{secret: clientSecret}}
/>
</p>,
);
if (outgoingOAuthConnection.grant_type === 'password') {
helpText.push(
<p key='add_outgoing_oauth_connection.username'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.username'
defaultMessage='**Username**: {username}'
values={{username}}
/>
<CopyText
idMessage='integrations.copy_username'
defaultMessage='Copy Username'
value={username || ''}
/>
<br/>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.password'
defaultMessage='**Password**: {password}'
values={{password}}
/>
</p>,
);
}
tokenText = (
<>
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.token_url'
defaultMessage='**Token URL**: `{url}`'
values={{url: outgoingOAuthConnection.oauth_token_url}}
/>
</p>
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.audience_urls'
defaultMessage='**Audience URL(s)**: `{url}`'
values={{url: outgoingOAuthConnection.audiences.join(', ')}}
/>
</p>
</>
);
} else if (type === Constants.Integrations.BOT && bot) {
const botToken = (new URLSearchParams(location.search)).get('token') || '';

Просмотреть файл

@@ -4,7 +4,7 @@
import {connect} from 'react-redux';
import {getBotAccounts} from 'mattermost-redux/selectors/entities/bots';
import {getCommands, getOAuthApps, getIncomingHooks, getOutgoingHooks} from 'mattermost-redux/selectors/entities/integrations';
import {getCommands, getOAuthApps, getIncomingHooks, getOutgoingHooks, getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import type {GlobalState} from 'types/store';
@@ -17,6 +17,7 @@ function mapStateToProps(state: GlobalState) {
incomingHooks: getIncomingHooks(state),
outgoingHooks: getOutgoingHooks(state),
bots: getBotAccounts(state),
outgoingOAuthConnections: getOutgoingOAuthConnections(state),
};
}

Просмотреть файл

@@ -14,6 +14,7 @@ const ModalId = 'delete_integration_confirm';
type Props = {
confirmButtonText?: React.ReactNode;
linkText?: React.ReactNode;
subtitleText?: React.ReactNode;
modalMessage?: React.ReactNode;
modalTitle?: React.ReactNode;
onDelete: () => void;
@@ -25,7 +26,7 @@ export default function DeleteIntegrationLink(props: Props) {
confirmButtonText = (
<FormattedMessage
id='integrations.delete.confirm.button'
defaultMessage='Delete'
defaultMessage='Yes, delete it'
/>
),
linkText = (
@@ -50,11 +51,22 @@ export default function DeleteIntegrationLink(props: Props) {
modalId: ModalId,
dialogProps: {
confirmButtonText,
confirmButtonClass: 'btn btn-danger',
modalClass: 'integrations-backstage-modal',
message: (
<div className='alert alert-warning'>
<WarningIcon additionalClassName='mr-1'/>
{props.modalMessage}
</div>
<>
{props.subtitleText && (
<p>
{props.subtitleText}
</p>
)}
<div className='alert alert-danger'>
<WarningIcon additionalClassName='mr-1'/>
<strong>
{props.modalMessage}
</strong>
</div>
</>
),
onConfirm: onDelete,
title: modalTitle,

Просмотреть файл

@@ -15,7 +15,7 @@ exports[`components/integrations/EditCommand should have match renderExtra 1`] =
id="update_command.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@@ -85,7 +85,7 @@ exports[`components/integrations/EditCommand should match snapshot 1`] = `
id="update_command.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}

Просмотреть файл

@@ -157,6 +157,7 @@ export default class EditCommand extends React.PureComponent<Props, State> {
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={this.state.showConfirmModal}
onConfirm={this.submitCommand}
onCancel={this.confirmModalDismissed}

Просмотреть файл

@@ -65,7 +65,6 @@ type Props = {
};
type State = {
showConfirmModal: boolean;
serverError: string;
};
@@ -76,7 +75,6 @@ export default class EditIncomingWebhook extends React.PureComponent<Props, Stat
super(props);
this.state = {
showConfirmModal: false,
serverError: '',
};
}

Просмотреть файл

@@ -15,7 +15,7 @@ exports[`components/integrations/EditOAuthApp should have match renderExtra 1`]
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@@ -82,7 +82,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot 1`] = `
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@@ -158,7 +158,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot when EnableO
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@@ -234,7 +234,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot, loading 1`]
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}

Просмотреть файл

@@ -127,6 +127,7 @@ export default class EditOAuthApp extends React.PureComponent<Props, State> {
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={this.state.showConfirmModal}
onConfirm={this.submitOAuthApp}
onCancel={this.confirmModalDismissed}

Просмотреть файл

@@ -15,7 +15,7 @@ exports[`components/integrations/EditOutgoingWebhook should have match renderExt
id="update_outgoing_webhook.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@@ -92,7 +92,7 @@ exports[`components/integrations/EditOutgoingWebhook should match snapshot 1`] =
id="update_outgoing_webhook.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@@ -191,7 +191,7 @@ exports[`components/integrations/EditOutgoingWebhook should match snapshot when
id="update_outgoing_webhook.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}

Просмотреть файл

@@ -163,6 +163,7 @@ export default class EditOutgoingWebhook extends React.PureComponent<Props, Stat
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={this.state.showConfirmModal}
onConfirm={this.submitHook}
onCancel={this.confirmModalDismissed}

Просмотреть файл

@@ -16,6 +16,7 @@ function mapStateToProps(state: GlobalState) {
const enableOutgoingWebhooks = config.EnableOutgoingWebhooks === 'true';
const enableCommands = config.EnableCommands === 'true';
const enableOAuthServiceProvider = config.EnableOAuthServiceProvider === 'true';
const enableOutgoingOAuthConnections = config.EnableOutgoingOAuthConnections === 'true';
return {
siteName,
@@ -23,6 +24,7 @@ function mapStateToProps(state: GlobalState) {
enableOutgoingWebhooks,
enableCommands,
enableOAuthServiceProvider,
enableOutgoingOAuthConnections,
};
}

Просмотреть файл

@@ -149,14 +149,14 @@ export default class InstalledCommand extends React.PureComponent<Props> {
<div className='backstage-list__item'>
<div className='item-details'>
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<div>
<strong className='item-details__name'>
<div className='item-details__name'>
<strong>
{name}
</strong>
<span className='item-details__trigger'>
{trigger}
</span>
</div>
<span className='item-details__trigger'>
{trigger}
</span>
{actions}
</div>
{description}

Просмотреть файл

@@ -15,6 +15,7 @@ import TeamPermissionGate from 'components/permissions_gates/team_permission_gat
import BotAccountsIcon from 'images/bot_default_icon.png';
import IncomingWebhookIcon from 'images/incoming_webhook.jpg';
import OAuthIcon from 'images/oauth_icon.png';
import OutgoingOAuthConnectionsIcon from 'images/outgoing_oauth_connection.png';
import OutgoingWebhookIcon from 'images/outgoing_webhook.jpg';
import SlashCommandIcon from 'images/slash_command_icon.jpg';
import * as Utils from 'utils/utils';
@@ -27,6 +28,7 @@ type Props = {
enableOutgoingWebhooks: boolean;
enableCommands: boolean;
enableOAuthServiceProvider: boolean;
enableOutgoingOAuthConnections: boolean;
team: Team;
}
@@ -154,6 +156,34 @@ export default class Integrations extends React.PureComponent <Props> {
);
}
if (this.props.enableOutgoingOAuthConnections) {
options.push(
<TeamPermissionGate
teamId={this.props.team.id}
permissions={[Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]}
key='outgoingOAuthConnectionsPermission'
>
<IntegrationOption
key='outgoingOAuthConnections'
image={OutgoingOAuthConnectionsIcon}
title={
<FormattedMessage
id='integrations.outgoingOAuthConnections.title'
defaultMessage='Outgoing OAuth Connections'
/>
}
description={
<FormattedMessage
id='integrations.outgoingOAuthConnections.description'
defaultMessage='Outgoing OAuth Connections allow custom integrations to communicate to external systems'
/>
}
link={'/' + this.props.team.name + '/integrations/outgoing-oauth2-connections'}
/>
</TeamPermissionGate>,
);
}
options.push(
<SystemPermissionGate
permissions={['manage_bots']}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -0,0 +1,559 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AddOutgoingOAuthConnection should match snapshot 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<AddOutgoingOAuthConnection
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
>
<AbstractOutgoingOAuthConnection
footer={
Object {
"defaultMessage": "Save",
"id": "add_outgoing_oauth_connection.save",
}
}
header={
Object {
"defaultMessage": "Add",
"id": "add_outgoing_oauth_connection.add",
}
}
loading={
Object {
"defaultMessage": "Saving...",
"id": "add_outgoing_oauth_connection.saving",
}
}
serverError=""
submitAction={[Function]}
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
>
<div
className="backstage-content"
>
<BackstageHeader>
<div
className="backstage-header"
>
<h1>
<Link
to="/test/integrations/outgoing-oauth2-connections"
>
<LinkAnchor
href="/test/integrations/outgoing-oauth2-connections"
navigate={[Function]}
>
<a
href="/test/integrations/outgoing-oauth2-connections"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Outgoing OAuth Connections"
id="add_outgoing_oauth_connection.header"
>
<span>
Outgoing OAuth Connections
</span>
</FormattedMessage>
</a>
</LinkAnchor>
</Link>
<span
className="backstage-header__divider"
key="divider1"
>
<i
className="fa fa-angle-right"
title="Breadcrumb Icon"
/>
</span>
<FormattedMessage
defaultMessage="Add"
id="add_outgoing_oauth_connection.add"
>
<span>
Add
</span>
</FormattedMessage>
</h1>
</div>
</BackstageHeader>
<div
className="backstage-form"
>
<form
className="form-horizontal"
>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="name"
>
<FormattedMessage
defaultMessage="Name"
id="add_outgoing_oauth_connection.name.label"
>
<span>
Name
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="name"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the name for your OAuth connection."
id="add_outgoing_oauth_connection.name.help"
>
<span>
Specify the name for your OAuth connection.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="client_id"
>
<FormattedMessage
defaultMessage="Client ID"
id="add_outgoing_oauth_connection.client_id.label"
>
<span>
Client ID
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
autoComplete="off"
className="form-control"
id="client_id"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the Client ID for your OAuth connection."
id="add_outgoing_oauth_connection.client_id.help"
>
<span>
Specify the Client ID for your OAuth connection.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="client_secret"
>
<FormattedMessage
defaultMessage="Client Secret"
id="add_outgoing_oauth_connection.client_secret.label"
>
<span>
Client Secret
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
autoComplete="off"
className="form-control"
id="client_secret"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the Client Secret for your OAuth connection."
id="add_outgoing_oauth_connection.client_secret.help"
>
<span>
Specify the Client Secret for your OAuth connection.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="oauth_token_url"
>
<FormattedMessage
defaultMessage="OAuth Token URL"
id="add_outgoing_oauth_connection.oauth_token_url.label"
>
<span>
OAuth Token URL
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="token_url"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the OAuth Token URL for your OAuth connection."
id="add_outgoing_oauth_connection.oauth_token_url.help"
>
<span>
Specify the OAuth Token URL for your OAuth connection.
</span>
</FormattedMessage>
</div>
<div
className="outgoing-oauth-connection-validate-button-container"
>
<ValidateButton
onClick={[Function]}
setUnvalidated={[Function]}
status="initial"
>
<button
className="btn btn-tertiary btn-sm"
id="validateConnection"
onClick={[Function]}
type="button"
>
<FormattedMessage
defaultMessage="Validate Connection"
id="add_outgoing_oauth_connection.validate"
>
<span>
Validate Connection
</span>
</FormattedMessage>
</button>
</ValidateButton>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="audienceUrls"
>
<FormattedMessage
defaultMessage="Audience URLs (One Per Line)"
id="add_outgoing_oauth_connection.audienceUrls.label"
>
<span>
Audience URLs (One Per Line)
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<textarea
className="form-control"
id="audienceUrls"
onChange={[Function]}
rows={3}
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="The URLs which will receive requests with the OAuth token, e.g. your custom slash command handler endpoint. Must be a valid URL and start with http:// or https://."
id="add_outgoing_oauth_connection.audienceUrls.help"
>
<span>
The URLs which will receive requests with the OAuth token, e.g. your custom slash command handler endpoint. Must be a valid URL and start with http:// or https://.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="backstage-form__footer"
>
<FormError
error={null}
errors={
Array [
"",
"",
]
}
type="backstage"
/>
<Link
className="btn btn-tertiary"
to="/test/integrations/outgoing-oauth2-connections"
>
<LinkAnchor
className="btn btn-tertiary"
href="/test/integrations/outgoing-oauth2-connections"
navigate={[Function]}
>
<a
className="btn btn-tertiary"
href="/test/integrations/outgoing-oauth2-connections"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="add_outgoing_oauth_connection.cancel"
>
<span>
Cancel
</span>
</FormattedMessage>
</a>
</LinkAnchor>
</Link>
<Memo(SpinnerButton)
className="btn btn-primary"
id="saveConnection"
onClick={[Function]}
spinning={false}
spinningText="Saving..."
type="submit"
>
<button
className="btn btn-primary"
disabled={false}
id="saveConnection"
onClick={[Function]}
type="submit"
>
<Memo(LoadingWrapper)
loading={false}
text="Saving..."
>
<FormattedMessage
defaultMessage="Save"
id="add_outgoing_oauth_connection.save"
>
<span>
Save
</span>
</FormattedMessage>
</Memo(LoadingWrapper)>
</button>
</Memo(SpinnerButton)>
</div>
</form>
</div>
<div
className="outgoing-oauth-connections-docs-link"
>
<FormattedMessage
defaultMessage="Get help with <link>configuring outgoing OAuth connections</link>."
id="add_outgoing_oauth_connection.documentation_link"
values={
Object {
"link": [Function],
}
}
>
<span>
Get help with
<a
href="https://mattermost.com/pl/outgoing-oauth-connections"
key=".$.1"
>
configuring outgoing OAuth connections
</a>
.
</span>
</FormattedMessage>
</div>
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText="Save anyway"
message="This connection has not been validated, Do you want to save anyway?"
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
onExited={[Function]}
show={false}
title="Save Outgoing OAuth Connection"
>
<Modal
animation={true}
aria-describedby="confirmModalBody"
aria-labelledby="confirmModalLabel"
aria-modal={true}
autoFocus={true}
backdrop={true}
bsClass="modal"
className="modal-confirm "
dialogClassName="a11y__modal"
dialogComponentClass={[Function]}
enforceFocus={true}
id="confirmModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={false}
>
<Modal
autoFocus={true}
backdrop={true}
backdropClassName="modal-backdrop"
backdropTransition={[Function]}
containerClassName="modal-open"
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onEntering={[Function]}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
show={false}
transition={[Function]}
/>
</Modal>
</ConfirmModal>
</div>
</AbstractOutgoingOAuthConnection>
</AddOutgoingOAuthConnection>
</Provider>
</Router>
</BrowserRouter>
`;

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -0,0 +1,137 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOutgoingOAuthConnection should match snapshot 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
My OAuth Connection
</strong>
<div
className="item-actions"
>
<Link
to="/team_name/integrations/outgoing-oauth2-connections/edit?id=someid"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deleting this connection will break any integrations using it"
id="installed_outgoing_oauth_connections.delete.wanring"
/>
}
onDelete={[Function]}
subtitleText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Are you sure you want to delete {connectionName}?"
id="installed_outgoing_oauth_connections.delete.confirm"
values={
Object {
"connectionName": <strong>
My OAuth Connection
</strong>,
}
}
/>
}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<FormattedMarkdownMessage
defaultMessage="Client ID: **{clientId}**"
id="installed_integrations.client_id"
values={
Object {
"clientId": "someid",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Client Secret: ********"
id="installed_outgoing_oauth_connections.client_secret"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Audience URLs: {urls}"
id="installed_integrations.audience_urls"
values={
Object {
"urls": "https://myaudience.com",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Token URL: {url}"
id="installed_integrations.token_url"
values={
Object {
"url": "https://tokenurl.com",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1501365458934,
"creator": "somename",
}
}
/>
</span>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,260 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOutgoingOAuthConnections should match snapshot 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<InstalledOutgoingOAuthConnections
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
>
<BackstageList
addButtonId="addOutgoingOauthConnection"
addLink="/test/integrations/outgoing-oauth2-connections/add"
addText="Add Outgoing OAuth Connection"
emptyText={
<Memo(MemoizedFormattedMessage)
defaultMessage="No Outgoing OAuth Connections found"
id="installed_outgoing_oauth_connections.empty"
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
defaultMessage="No Outgoing OAuth Connections match {searchTerm}"
id="installed_outgoing_oauth_connections.emptySearch"
/>
}
header={
<Memo(MemoizedFormattedMessage)
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.header"
/>
}
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost."
id="installed_outgoing_oauth_connections.help"
values={
Object {
"outgoingOauthConnections": <ExternalLink
href="https://mattermost.com/pl/setup-oauth-2.0"
location="installed_outgoing_oauth_connections"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
/>
</ExternalLink>,
}
}
/>
}
loading={true}
searchPlaceholder="Search Outgoing OAuth Connections"
>
<div
className="backstage-content"
>
<div
className="backstage-header"
>
<h1>
<FormattedMessage
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.header"
>
<span>
Outgoing OAuth Connections
</span>
</FormattedMessage>
</h1>
<Link
className="add-link"
to="/test/integrations/outgoing-oauth2-connections/add"
>
<LinkAnchor
className="add-link"
href="/test/integrations/outgoing-oauth2-connections/add"
navigate={[Function]}
>
<a
className="add-link"
href="/test/integrations/outgoing-oauth2-connections/add"
onClick={[Function]}
>
<button
className="btn btn-primary"
id="addOutgoingOauthConnection"
type="button"
>
<span>
Add Outgoing OAuth Connection
</span>
</button>
</a>
</LinkAnchor>
</Link>
</div>
<div
className="backstage-filters"
>
<div
className="backstage-filter__search"
>
<SearchIcon>
<i
className="fa fa-search"
title="Search Icon"
/>
</SearchIcon>
<input
className="form-control"
id="searchInput"
onChange={[Function]}
placeholder="Search Outgoing OAuth Connections"
style={
Object {
"flexGrow": 0,
"flexShrink": 0,
}
}
type="search"
value=""
/>
</div>
</div>
<span
className="backstage-list__help"
>
<FormattedMessage
defaultMessage="Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost."
id="installed_outgoing_oauth_connections.help"
values={
Object {
"outgoingOauthConnections": <ExternalLink
href="https://mattermost.com/pl/setup-oauth-2.0"
location="installed_outgoing_oauth_connections"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
/>
</ExternalLink>,
}
}
>
<span>
Create
<ExternalLink
href="https://mattermost.com/pl/setup-oauth-2.0"
key=".$.1"
location="installed_outgoing_oauth_connections"
>
<a
href="https://mattermost.com/pl/setup-oauth-2.0?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=installed_outgoing_oauth_connections&uid=current_user_id&sid="
location="installed_outgoing_oauth_connections"
onClick={[Function]}
rel="noopener noreferrer"
target="_blank"
>
<FormattedMessage
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
>
<span>
Outgoing OAuth Connections
</span>
</FormattedMessage>
</a>
</ExternalLink>
to securely integrate bots and third-party apps with Mattermost.
</span>
</FormattedMessage>
</span>
<div
className="backstage-list"
>
<LoadingScreen>
<div
className="loading-screen"
style={
Object {
"position": "relative",
}
}
>
<div
className="loading__content"
>
<p>
Loading
</p>
<div
className="round round-1"
/>
<div
className="round round-2"
/>
<div
className="round round-3"
/>
</div>
</div>
</LoadingScreen>
</div>
</div>
</BackstageList>
</InstalledOutgoingOAuthConnections>
</Provider>
</Router>
</BrowserRouter>
`;

Просмотреть файл

@@ -0,0 +1,417 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot when an audience url with a wildcard is configured, and typed in value starts with configured audience url 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value="https://aud.com/api/it_matches"
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value="https://aud.com/api/it_matches"
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot when typed in value does not have an exact match 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value="https://aud.com/api/no_match"
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value="https://aud.com/api/no_match"
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot when typed in value matches a configured audience 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value="https://aud.com/api"
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value="https://aud.com/api"
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot with existing connections 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value=""
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value=""
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot with no existing connections 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value=""
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value=""
/>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;

Просмотреть файл

@@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {act} from 'react-dom/test-utils';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import AbstractOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/abstract_outgoing_oauth_connection';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AbstractOutgoingOAuthConnection', () => {
const header = {id: 'Header', defaultMessage: 'Header'};
const footer = {id: 'Footer', defaultMessage: 'Footer'};
const loading = {id: 'Loading', defaultMessage: 'Loading'};
const initialConnection: OutgoingOAuthConnection = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testConnection',
client_secret: '',
client_id: 'clientid',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
grant_type: 'client_credentials',
oauth_token_url: 'https://token.com',
audiences: ['https://aud.com'],
};
const outgoingOAuthConnections: Record<string, OutgoingOAuthConnection> = {
facxd9wpzpbpfp8pad78xj75pr: initialConnection,
};
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections,
},
},
};
const team = TestHelper.getTeamMock({name: 'test', id: initialConnection.id});
const baseProps: React.ComponentProps<typeof AbstractOutgoingOAuthConnection> = {
team,
header,
footer,
loading,
renderExtra: <div>{'renderExtra'}</div>,
serverError: '',
initialConnection,
submitAction: jest.fn(),
};
test('should match snapshot', () => {
const props = {...baseProps};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AbstractOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, displays client error', () => {
const submitAction = jest.fn().mockResolvedValue({data: true});
const newServerError = 'serverError';
const props = {...baseProps, serverError: newServerError, submitAction};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AbstractOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
wrapper.find('#audienceUrls').simulate('change', {target: {value: ''}});
wrapper.find('button.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(submitAction).not.toBeCalled();
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('FormError').exists()).toBe(true);
});
test('should call action function', async () => {
const submitAction = jest.fn().mockResolvedValue({data: true});
const props = {...baseProps, submitAction};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AbstractOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
await act(async () => {
wrapper.find('#name').simulate('change', {target: {value: 'name'}});
wrapper.find('button.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(submitAction).toBeCalled();
});
});
});

Просмотреть файл

@@ -0,0 +1,632 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChangeEvent, FormEvent} from 'react';
import React, {useMemo, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {MessageDescriptor} from 'react-intl';
import {useDispatch} from 'react-redux';
import {Link} from 'react-router-dom';
import {AlertOutlineIcon, CheckCircleOutlineIcon} from '@mattermost/compass-icons/components';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {validateOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import BackstageHeader from 'components/backstage/components/backstage_header';
import ConfirmModal from 'components/confirm_modal';
import FormError from 'components/form_error';
import SpinnerButton from 'components/spinner_button';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
type Props = {
team: Team;
header: MessageDescriptor;
footer: MessageDescriptor;
loading: MessageDescriptor;
renderExtra?: JSX.Element;
serverError: string;
initialConnection?: OutgoingOAuthConnection;
submitAction: (connection: OutgoingOAuthConnection) => Promise<void>;
}
type State = {
name: string;
oauthTokenUrl: string;
grantType: OutgoingOAuthConnection['grant_type'];
clientId: string;
clientSecret: string;
audienceUrls: string;
};
enum ValidationStatus {
INITIAL = 'initial',
DIRTY = 'dirty',
VALIDATING = 'validating',
VALIDATED = 'validated',
ERROR = 'error',
}
const useOutgoingOAuthForm = (connection: OutgoingOAuthConnection): [State, (state: Partial<State>) => void] => {
const initialState: State = {
name: connection.name || '',
audienceUrls: connection.audiences ? connection.audiences.join('\n') : '',
oauthTokenUrl: connection.oauth_token_url || '',
clientId: connection.client_id || '',
clientSecret: connection.client_secret || '',
grantType: 'client_credentials',
};
const [state, setState] = useState(initialState);
return useMemo(() => [state, (newState: Partial<State>) => {
setState((oldState) => ({...oldState, ...newState}));
}], [state]);
};
const initialState: OutgoingOAuthConnection = {
id: '',
name: '',
creator_id: '',
create_at: 0,
update_at: 0,
client_id: '',
client_secret: '',
oauth_token_url: '',
grant_type: 'client_credentials',
audiences: [],
};
export default function AbstractOutgoingOAuthConnection(props: Props) {
const [formState, setFormState] = useOutgoingOAuthForm(props.initialConnection || initialState);
const [storedError, setError] = useState<React.ReactNode>('');
const [validationError, setValidationError] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [validationStatus, setValidationStatus] = useState<ValidationStatus>(ValidationStatus.INITIAL);
const [isEditingSecret, setIsEditingSecret] = useState(false);
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
const intl = useIntl();
const dispatch = useDispatch();
const isNewConnection = !props.initialConnection;
const parseForm = (requireAudienceUrl: boolean): OutgoingOAuthConnection | undefined => {
if (!formState.name) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.name.required'
defaultMessage='Name for the OAuth connection is required.'
/>,
);
return undefined;
}
if (!formState.clientId) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.client_id.required'
defaultMessage='Client Id for the OAuth connection is required.'
/>,
);
return undefined;
}
if ((isNewConnection || isEditingSecret) && !formState.clientSecret) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.client_secret.required'
defaultMessage='Client Secret for the OAuth connection is required.'
/>,
);
return undefined;
}
if (!formState.grantType) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.grant_type.required'
defaultMessage='Grant Type for the OAuth connection is required.'
/>,
);
return undefined;
}
if (!formState.oauthTokenUrl) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.oauth_token_url.required'
defaultMessage='OAuth Token URL for the OAuth connection is required.'
/>,
);
return undefined;
}
const audienceUrls = [];
for (let audienceUrl of formState.audienceUrls.split('\n')) {
audienceUrl = audienceUrl.trim();
if (audienceUrl.length > 0) {
audienceUrls.push(audienceUrl);
}
}
if (requireAudienceUrl && audienceUrls.length === 0) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.audienceUrls.required'
defaultMessage='One or more audience URLs are required.'
/>,
);
return undefined;
}
const connection = {
name: formState.name,
audiences: audienceUrls,
client_id: formState.clientId,
client_secret: formState.clientSecret,
grant_type: formState.grantType,
oauth_token_url: formState.oauthTokenUrl,
} as OutgoingOAuthConnection;
return connection;
};
const showSkipValidateModal = () => {
setIsValidationModalOpen(true);
};
const hideSkipValidateModal = () => {
setIsValidationModalOpen(false);
};
const handleSubmitFromButton = (e: FormEvent) => {
e.preventDefault();
handleSubmit();
};
const handleSubmit = () => {
if (isSubmitting) {
return;
}
const connection = parseForm(true);
if (!connection) {
return;
}
setError('');
if (validationStatus !== ValidationStatus.VALIDATED && !(!isNewConnection && validationStatus === ValidationStatus.INITIAL)) {
if (!isValidationModalOpen) {
showSkipValidateModal();
return;
}
}
setIsSubmitting(true);
const res = props.submitAction(connection);
res.then(() => setIsSubmitting(false));
};
const handleValidate = async (e: FormEvent) => {
e.preventDefault();
if (validationStatus === ValidationStatus.VALIDATING) {
return;
}
setError('');
setValidationStatus(ValidationStatus.VALIDATING);
const connection = parseForm(false);
if (!connection) {
// Defer to the form validation error
setValidationStatus(ValidationStatus.INITIAL);
return;
}
if (props.initialConnection?.id) {
connection.id = props.initialConnection.id;
}
const {error} = await dispatch(validateOutgoingOAuthConnection(props.team.id, connection));
if (error) {
setValidationStatus(ValidationStatus.ERROR);
setValidationError(error.message);
} else {
setValidationStatus(ValidationStatus.VALIDATED);
}
};
const setUnvalidated = (e?: React.FormEvent) => {
e?.preventDefault();
if (validationStatus !== ValidationStatus.DIRTY) {
setValidationStatus(ValidationStatus.DIRTY);
}
if (validationError) {
setValidationError('');
}
};
const updateName = (e: ChangeEvent<HTMLInputElement>) => {
setFormState({
name: e.target.value,
});
};
const updateClientId = (e: ChangeEvent<HTMLInputElement>) => {
setUnvalidated();
setFormState({
clientId: e.target.value,
});
};
const updateClientSecret = (e: ChangeEvent<HTMLInputElement>) => {
setUnvalidated();
setFormState({
clientSecret: e.target.value,
});
};
const updateOAuthTokenURL = (e: ChangeEvent<HTMLInputElement>) => {
setUnvalidated();
setFormState({
oauthTokenUrl: e.target.value,
});
};
const updateAudienceUrls = (e: ChangeEvent<HTMLTextAreaElement>) => {
setFormState({
audienceUrls: e.target.value,
});
};
const startEditingClientSecret = () => {
setIsEditingSecret(true);
};
const headerToRender = props.header;
const footerToRender = props.footer;
let clientSecretSection = (
<input
id='client_secret'
type='text'
autoComplete='off'
className='form-control'
value={formState.clientSecret}
onChange={updateClientSecret}
/>
);
if (!isNewConnection && !isEditingSecret) {
clientSecretSection = (
<>
<input
id='client_secret'
disabled={true}
autoComplete='off'
type='text'
className='form-control disabled'
value={'•'.repeat(40)}
/>
<span
onClick={startEditingClientSecret}
className='outgoing-oauth-connections-edit-secret'
>
<i className='icon icon-pencil-outline'/>
</span>
</>
);
}
return (
<div className='backstage-content'>
<BackstageHeader>
<Link to={`/${props.team.name}/integrations/outgoing-oauth2-connections`}>
<FormattedMessage
id='add_outgoing_oauth_connection.header'
defaultMessage='Outgoing OAuth Connections'
/>
</Link>
<FormattedMessage
id={headerToRender.id}
defaultMessage={headerToRender.defaultMessage}
/>
</BackstageHeader>
<div className='backstage-form'>
<form className='form-horizontal'>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='name'
>
<FormattedMessage
id='add_outgoing_oauth_connection.name.label'
defaultMessage='Name'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='name'
type='text'
className='form-control'
value={formState.name}
onChange={updateName}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.name.help'
defaultMessage='Specify the name for your OAuth connection.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='client_id'
>
<FormattedMessage
id='add_outgoing_oauth_connection.client_id.label'
defaultMessage='Client ID'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='client_id'
type='text'
autoComplete='off'
className='form-control'
value={formState.clientId}
onChange={updateClientId}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.client_id.help'
defaultMessage='Specify the Client ID for your OAuth connection.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='client_secret'
>
<FormattedMessage
id='add_outgoing_oauth_connection.client_secret.label'
defaultMessage='Client Secret'
/>
</label>
<div className='col-md-5 col-sm-8'>
{clientSecretSection}
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.client_secret.help'
defaultMessage='Specify the Client Secret for your OAuth connection.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='oauth_token_url'
>
<FormattedMessage
id='add_outgoing_oauth_connection.oauth_token_url.label'
defaultMessage='OAuth Token URL'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='token_url'
type='text'
className='form-control'
value={formState.oauthTokenUrl}
onChange={updateOAuthTokenURL}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.oauth_token_url.help'
defaultMessage='Specify the OAuth Token URL for your OAuth connection.'
/>
</div>
<div className='outgoing-oauth-connection-validate-button-container'>
<ValidateButton
onClick={handleValidate}
setUnvalidated={setUnvalidated}
status={validationStatus}
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='audienceUrls'
>
<FormattedMessage
id='add_outgoing_oauth_connection.audienceUrls.label'
defaultMessage='Audience URLs (One Per Line)'
/>
</label>
<div className='col-md-5 col-sm-8'>
<textarea
id='audienceUrls'
rows={3}
className='form-control'
value={formState.audienceUrls}
onChange={updateAudienceUrls}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.audienceUrls.help'
defaultMessage='The URLs which will receive requests with the OAuth token, e.g. your custom slash command handler endpoint. Must be a valid URL and start with http:// or https://.'
/>
</div>
</div>
</div>
<div className='backstage-form__footer'>
<FormError
type='backstage'
errors={[props.serverError, storedError]}
/>
<Link
className='btn btn-tertiary'
to={`/${props.team.name}/integrations/outgoing-oauth2-connections`}
>
<FormattedMessage
id='add_outgoing_oauth_connection.cancel'
defaultMessage='Cancel'
/>
</Link>
<SpinnerButton
className='btn btn-primary'
type='submit'
spinning={isSubmitting}
spinningText={intl.formatMessage(props.loading)}
onClick={handleSubmitFromButton}
id='saveConnection'
>
<FormattedMessage
id={footerToRender.id}
defaultMessage={footerToRender.defaultMessage}
/>
</SpinnerButton>
{props.renderExtra}
</div>
</form>
</div>
<div className='outgoing-oauth-connections-docs-link'>
<FormattedMessage
id={'add_outgoing_oauth_connection.documentation_link'}
defaultMessage={'Get help with <link>configuring outgoing OAuth connections</link>.'}
values={{
link: (text: string) => (
<a href='https://mattermost.com/pl/outgoing-oauth-connections'>{text}</a>
),
}}
/>
</div>
<ConfirmModal
show={isValidationModalOpen}
message={intl.formatMessage({
id: 'add_outgoing_oauth_connection.save_without_validation_warning',
defaultMessage: 'This connection has not been validated, Do you want to save anyway?',
})}
title={intl.formatMessage({
id: 'add_outgoing_oauth_connection.confirm_save',
defaultMessage: 'Save Outgoing OAuth Connection',
})}
confirmButtonText={intl.formatMessage({
id: 'add_outgoing_oauth_connection.save_anyway',
defaultMessage: 'Save anyway',
})}
onExited={hideSkipValidateModal}
onCancel={hideSkipValidateModal}
onConfirm={handleSubmit}
/>
</div>
);
}
type ValidateButtonProps = {
status: ValidationStatus;
onClick: (e: FormEvent) => void;
setUnvalidated: (e: FormEvent) => void;
}
const ValidateButton = ({status, onClick, setUnvalidated}: ValidateButtonProps) => {
if (status === ValidationStatus.ERROR) {
return (
<span
className='outgoing-oauth-connection-validation-message validation-error'
>
<AlertOutlineIcon size={20}/>
<FormattedMessage
id={'add_outgoing_oauth_connection.validation_error'}
defaultMessage={'Connection not validated. Please check the server logs for details or <link>try again</link>.'}
values={{
link: (text: string) => <a onClick={setUnvalidated}>{text}</a>,
}}
/>
</span>
);
}
if (status === ValidationStatus.VALIDATED) {
return (
<span
className='outgoing-oauth-connection-validation-message validation-success'
>
<CheckCircleOutlineIcon size={20}/>
<FormattedMessage
id={'add_outgoing_oauth_connection.validated_connection'}
defaultMessage={'Validated connection'}
/>
</span>
);
}
if (status === ValidationStatus.VALIDATING) {
return (
<span
className='outgoing-oauth-connection-validation-message'
>
<LoadingSpinner
text={(
<FormattedMessage
id={'add_outgoing_oauth_connection.validating'}
defaultMessage={'Validating...'}
/>
)}
/>
</span>
);
}
const validateButton = (
<button
className='btn btn-tertiary btn-sm'
type='button'
onClick={onClick}
id='validateConnection'
>
<FormattedMessage
id={'add_outgoing_oauth_connection.validate'}
defaultMessage={'Validate Connection'}
/>
</button>
);
return validateButton;
};

Просмотреть файл

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import {Permissions} from 'mattermost-redux/constants';
import AddOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/add_outgoing_oauth_connection';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AddOutgoingOAuthConnection', () => {
const team = TestHelper.getTeamMock({
id: 'dbcxd9wpzpbpfp8pad78xj12pr',
name: 'test',
});
test('should match snapshot', () => {
const baseProps: React.ComponentProps<typeof AddOutgoingOAuthConnection> = {
team,
};
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
},
};
const props = {...baseProps};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AddOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {defineMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {useHistory} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {addOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import AbstractOutgoingOAuthConnection from './abstract_outgoing_oauth_connection';
const HEADER = defineMessage({id: 'add_outgoing_oauth_connection.add', defaultMessage: 'Add'});
const FOOTER = defineMessage({id: 'add_outgoing_oauth_connection.save', defaultMessage: 'Save'});
const LOADING = defineMessage({id: 'add_outgoing_oauth_connection.saving', defaultMessage: 'Saving...'});
export type Props = {
team: Team;
};
const AddOutgoingOAuthConnection = ({team}: Props): JSX.Element => {
const dispatch = useDispatch();
const history = useHistory();
const [serverError, setServerError] = useState('');
const submit = async (connection: OutgoingOAuthConnection) => {
setServerError('');
const {data, error} = (await dispatch(addOutgoingOAuthConnection(team.id, connection))) as unknown as {data: OutgoingOAuthConnection; error: Error};
if (data) {
history.push(`/${team.name}/integrations/confirm?type=outgoing-oauth2-connections&id=${data.id}`);
return;
}
if (error) {
setServerError(error.message);
}
};
return (
<AbstractOutgoingOAuthConnection
team={team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
submitAction={submit}
serverError={serverError}
/>
);
};
export default AddOutgoingOAuthConnection;

Просмотреть файл

@@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import EditOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/edit_outgoing_oauth_connection';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/EditOutgoingOAuthConnection', () => {
const team = TestHelper.getTeamMock({
id: 'dbcxd9wpzpbpfp8pad78xj12pr',
name: 'test',
});
const outgoingOAuthConnections: Record<string, OutgoingOAuthConnection> = {
facxd9wpzpbpfp8pad78xj75pr: {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'firstConnection',
client_secret: '',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
audiences: ['https://mysite.com/api', 'https://myothersite.com/api/v2'],
client_id: 'client1',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken1.com/oauth/token',
},
fzcxd9wpzpbpfp8pad78xj75pr: {
id: 'fzcxd9wpzpbpfp8pad78xj75pr',
name: 'secondConnection',
client_secret: '',
create_at: 1501365458935,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458935,
audiences: ['https://myaudience.com/api'],
client_id: 'client2',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken2.com/oauth/token',
},
};
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections,
},
},
};
const connection: OutgoingOAuthConnection = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testApp',
client_secret: '88cxd9wpzpbpfp8pad78xj75pr',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
audiences: ['https://test.com/callback', 'https://test.com/callback2'],
client_id: 'someclientid',
grant_type: 'client_credentials',
oauth_token_url: 'https://mytoken.url',
};
const baseProps: React.ComponentProps<typeof EditOutgoingOAuthConnection> = {
team,
location: {
search: '?id=facxd9wpzpbpfp8pad78xj75pr',
} as any,
};
test('should match snapshot, loading', () => {
const props = {...baseProps, oauthApp: connection};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<EditOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot', () => {
const props = {...baseProps, oauthApp: connection};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<EditOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot when EnableOAuthServiceProvider is false', () => {
const props = {...baseProps, oauthApp: connection, enableOAuthServiceProvider: false};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<EditOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, defineMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {editOutgoingOAuthConnection, getOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import ConfirmModal from 'components/confirm_modal';
import LoadingScreen from 'components/loading_screen';
import {getHistory} from 'utils/browser_history';
import AbstractOutgoingOAuthConnection from './abstract_outgoing_oauth_connection';
const HEADER = defineMessage({id: 'integrations.edit', defaultMessage: 'Edit'});
const FOOTER = defineMessage({id: 'edit_outgoing_oauth_connection.update', defaultMessage: 'Update'});
const LOADING = defineMessage({id: 'edit_outgoing_oauth_connection.updating', defaultMessage: 'Updating...'});
type Props = {
team: Team;
location: Location;
};
const getConnectionIdFromSearch = (search: string): string => {
return (new URLSearchParams(search)).get('id') || '';
};
const EditOutgoingOAuthConnection = (props: Props) => {
const connectionId = getConnectionIdFromSearch(props.location.search);
const connections = useSelector(getOutgoingOAuthConnections);
const existingConnection = connections[connectionId];
const [newConnection, setNewConnection] = useState(existingConnection);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [serverError, setServerError] = useState('');
const enableOAuthServiceProvider = useSelector(getConfig).EnableOAuthServiceProvider;
const dispatch = useDispatch();
useEffect(() => {
if (enableOAuthServiceProvider) {
dispatch(getOutgoingOAuthConnection(props.team.id, connectionId));
}
}, [connectionId, enableOAuthServiceProvider, props.team, dispatch]);
const handleInitialSubmit = async (connection: OutgoingOAuthConnection) => {
setNewConnection(connection);
if (existingConnection.id) {
connection.id = existingConnection.id;
}
const audienceUrlsSame = (existingConnection.audiences.length === connection.audiences.length) &&
existingConnection.audiences.every((v, i) => v === connection.audiences[i]);
if (audienceUrlsSame) {
await createOutgoingOAuthConnection(connection);
} else {
handleConfirmModal();
}
};
const handleConfirmModal = () => {
setShowConfirmModal(true);
};
const confirmModalDismissed = () => {
setShowConfirmModal(false);
};
const createOutgoingOAuthConnection = async (connection: OutgoingOAuthConnection) => {
setServerError('');
const res = await dispatch(editOutgoingOAuthConnection(props.team.id, connection));
if ('data' in res && res.data) {
getHistory().push(`/${props.team.name}/integrations/outgoing-oauth2-connections`);
return;
}
confirmModalDismissed();
if ('error' in res) {
const {error: err} = res as {error: Error};
setServerError(err.message);
}
};
const renderExtra = () => {
const confirmButton = (
<FormattedMessage
id='update_command.update'
defaultMessage='Update'
/>
);
const confirmTitle = (
<FormattedMessage
id='update_outgoing_oauth_connection.confirm'
defaultMessage='Edit Outgoing OAuth Connection'
/>
);
const confirmMessage = (
<FormattedMessage
id='update_outgoing_oauth_connection.question'
defaultMessage='Your changes may break any existing integrations using this connection. Are you sure you would like to update it?'
/>
);
return (
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={showConfirmModal}
onConfirm={() => createOutgoingOAuthConnection(newConnection)}
onCancel={confirmModalDismissed}
/>
);
};
if (!existingConnection) {
return <LoadingScreen/>;
}
return (
<AbstractOutgoingOAuthConnection
team={props.team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={renderExtra()}
submitAction={handleInitialSubmit}
serverError={serverError}
initialConnection={existingConnection}
/>
);
};
export default EditOutgoingOAuthConnection;

Просмотреть файл

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import DeleteIntegrationLink from 'components/integrations/delete_integration_link';
import type {InstalledOutgoingOAuthConnectionProps} from 'components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connection';
import InstalledOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connection';
describe('components/integrations/InstalledOutgoingOAuthConnection', () => {
const team = {name: 'team_name'};
const outgoingOAuthConnection: OutgoingOAuthConnection = {
id: 'someid',
create_at: 1501365458934,
creator_id: 'someuserid',
update_at: 1501365458934,
audiences: ['https://myaudience.com'],
client_id: 'someid',
client_secret: '',
grant_type: 'client_credentials',
name: 'My OAuth Connection',
oauth_token_url: 'https://tokenurl.com',
};
const baseProps: InstalledOutgoingOAuthConnectionProps = {
team,
outgoingOAuthConnection,
creatorName: 'somename',
onDelete: jest.fn(),
filter: '',
};
test('should match snapshot', () => {
const props = {...baseProps};
const wrapper = shallow(
<InstalledOutgoingOAuthConnection {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should have called props.onDelete on handleDelete ', () => {
const newOnDelete = jest.fn();
const props = {...baseProps, team, onDelete: newOnDelete};
const wrapper = shallow(
<InstalledOutgoingOAuthConnection {...props}/>,
);
expect(wrapper.find(DeleteIntegrationLink).exists()).toBe(true);
wrapper.find(DeleteIntegrationLink).props().onDelete();
expect(newOnDelete).toBeCalled();
expect(newOnDelete).toHaveBeenCalledWith(outgoingOAuthConnection);
});
});

Просмотреть файл

@@ -0,0 +1,197 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import DeleteIntegrationLink from '../delete_integration_link';
export function matchesFilter(outgoingOAuthConnection: OutgoingOAuthConnection, filter?: string | null): boolean {
if (!filter) {
return true;
}
return outgoingOAuthConnection.name.toLowerCase().includes(filter);
}
export type InstalledOutgoingOAuthConnectionProps = {
team: Partial<Team>;
outgoingOAuthConnection: OutgoingOAuthConnection;
creatorName: string;
filter?: string | null;
onDelete: (outgoingOAuthConnection: OutgoingOAuthConnection) => void;
}
export type InstalledOutgoingOAuthConnectionState = {
clientSecret: string;
error?: string | null;
}
const InstalledOutgoingOAuthConnection = (props: InstalledOutgoingOAuthConnectionProps) => {
const handleDelete = (): void => {
props.onDelete(props.outgoingOAuthConnection);
};
const {outgoingOAuthConnection, creatorName} = props;
if (!matchesFilter(outgoingOAuthConnection, props.filter)) {
return null;
}
let name;
if (outgoingOAuthConnection.name) {
name = outgoingOAuthConnection.name;
} else {
name = (
<FormattedMessage
id='installed_integrations.unnamed_outgoing_oauth_connection'
defaultMessage='Unnamed Outgoing OAuth Connection'
/>
);
}
const urls = (
<>
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMessage
id='installed_integrations.audience_urls'
defaultMessage='Audience URLs: {urls}'
values={{
urls: outgoingOAuthConnection.audiences.join(', '),
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMessage
id='installed_integrations.token_url'
defaultMessage='Token URL: {url}'
values={{
url: outgoingOAuthConnection.oauth_token_url,
}}
/>
</span>
</div>
</>
);
const actions = (
<div className='item-actions'>
<Link to={`/${props.team.name}/integrations/outgoing-oauth2-connections/edit?id=${outgoingOAuthConnection.id}`}>
<FormattedMessage
id='installed_integrations.edit'
defaultMessage='Edit'
/>
</Link>
{' - '}
<DeleteIntegrationLink
subtitleText={
<FormattedMessage
id='installed_outgoing_oauth_connections.delete.confirm'
defaultMessage='Are you sure you want to delete {connectionName}?'
values={{
connectionName: (
<strong>
{props.outgoingOAuthConnection.name}
</strong>
),
}}
/>
}
modalMessage={
<FormattedMessage
id='installed_outgoing_oauth_connections.delete.wanring'
defaultMessage='Deleting this connection will break any integrations using it'
/>
}
onDelete={handleDelete}
/>
</div>
);
const connectionInfo = (
<>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMarkdownMessage
id='installed_integrations.client_id'
defaultMessage='Client ID: **{clientId}**'
values={{
clientId: outgoingOAuthConnection.client_id,
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMessage
id='installed_outgoing_oauth_connections.client_secret'
defaultMessage='Client Secret: ********'
/>
</span>
</div>
{outgoingOAuthConnection.grant_type === 'password' && (
<>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMarkdownMessage
id='installed_outgoing_oauth_connections.username'
defaultMessage='Username: **{username}**'
values={{
username: outgoingOAuthConnection.credentials_username,
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMessage
id='installed_outgoing_oauth_connections.password'
defaultMessage='Password: ********'
/>
</span>
</div>
</>
)}
{urls}
<div className='item-details__row'>
<span className='item-details__creation'>
<FormattedMessage
id='installed_integrations.creation'
defaultMessage='Created by {creator} on {createAt, date, full}'
values={{
creator: creatorName,
createAt: outgoingOAuthConnection.create_at,
}}
/>
</span>
</div>
</>
);
return (
<div className='backstage-list__item' >
<div className='item-details' >
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<strong className='item-details__name'>
{name}
</strong>
{actions}
</div>
{connectionInfo}
</div>
</div >
);
};
export default InstalledOutgoingOAuthConnection;

Просмотреть файл

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {act} from 'react-dom/test-utils';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import InstalledOutgoingOAuthConnections from 'components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connections';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/InstalledOutgoingOAuthConnections', () => {
const outgoingOAuthConnections: Record<string, OutgoingOAuthConnection> = {
facxd9wpzpbpfp8pad78xj75pr: {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'firstConnection',
client_secret: '',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
audiences: ['https://mysite.com/api', 'https://myothersite.com/api/v2'],
client_id: 'client1',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken1.com/oauth/token',
},
fzcxd9wpzpbpfp8pad78xj75pr: {
id: 'fzcxd9wpzpbpfp8pad78xj75pr',
name: 'secondConnection',
client_secret: '',
create_at: 1501365458935,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458935,
audiences: ['https://myaudience.com/api'],
client_id: 'client2',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken2.com/oauth/token',
},
};
const team = TestHelper.getTeamMock({name: 'test'});
const baseProps: React.ComponentProps<typeof InstalledOutgoingOAuthConnections> = {
team,
};
test('should match snapshot', async () => {
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections,
},
},
};
const props = {...baseProps};
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<InstalledOutgoingOAuthConnections {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
});

Просмотреть файл

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {deleteOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import {Permissions} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {loadOutgoingOAuthConnectionsAndProfiles} from 'actions/integration_actions';
import BackstageList from 'components/backstage/components/backstage_list';
import ExternalLink from 'components/external_link';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {DeveloperLinks} from 'utils/constants';
import type {GlobalState} from 'types/store';
import InstalledOutgoingOAuthConnection, {matchesFilter} from './installed_outgoing_oauth_connection';
type Props = {
team: Team;
};
const InstalledOutgoingOAuthConnections = (props: Props) => {
const [loading, setLoading] = useState(true);
const canManageOutgoingOAuthConnections = useSelector((state) => haveITeamPermission(state as GlobalState, props.team.id, Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS));
const enableOutgoingOAuthConnections = (useSelector(getConfig).EnableOutgoingOAuthConnections === 'true');
const connections = useSelector(getOutgoingOAuthConnections);
const dispatch = useDispatch();
const intl = useIntl();
useEffect(() => {
if (canManageOutgoingOAuthConnections) {
(dispatch(loadOutgoingOAuthConnectionsAndProfiles(props.team.id)) as unknown as Promise<void>).then(
() => setLoading(false),
);
}
}, [canManageOutgoingOAuthConnections, props.team, dispatch]);
const deleteOutgoingOAuthConnectionLocal = (connection: OutgoingOAuthConnection): void => {
if (connection && connection.id) {
dispatch(deleteOutgoingOAuthConnection(connection.id));
}
};
const outgoingOauthConnectionCompare = (a: OutgoingOAuthConnection, b: OutgoingOAuthConnection): number => {
let nameA = a.name.toString();
if (!nameA) {
nameA = intl.formatMessage({id: 'installed_integrations.unnamed_outgoing_oauth_connection', defaultMessage: 'Unnamed Outgoing OAuth Connection'});
}
let nameB = b.name.toString();
if (!nameB) {
nameB = intl.formatMessage({id: 'installed_integrations.unnamed_outgoing_oauth_connection', defaultMessage: 'Unnamed Outgoing OAuth Connection'});
}
return nameA.localeCompare(nameB);
};
const outgoingOauthConnections = (filter?: string) => {
const values = Object.values(connections);
const filtered = values.filter((connection) => matchesFilter(connection, filter));
const sorted = filtered.sort(outgoingOauthConnectionCompare);
const mapped = sorted.map((connection) => {
return (
<InstalledOutgoingOAuthConnection
key={connection.id}
outgoingOAuthConnection={connection}
onDelete={deleteOutgoingOAuthConnectionLocal}
team={props.team}
creatorName=''
/>
);
});
return mapped;
};
const integrationsEnabled = enableOutgoingOAuthConnections && canManageOutgoingOAuthConnections;
let childProps;
if (integrationsEnabled) {
childProps = {
addLink: '/' + props.team.name + '/integrations/outgoing-oauth2-connections/add',
addText: intl.formatMessage({id: 'installed_outgoing_oauth_connections.add', defaultMessage: 'Add Outgoing OAuth Connection'}),
addButtonId: 'addOutgoingOauthConnection',
};
}
return (
<BackstageList
header={
<FormattedMessage
id='installed_outgoing_oauth_connections.header'
defaultMessage='Outgoing OAuth Connections'
/>
}
helpText={
<FormattedMessage
id='installed_outgoing_oauth_connections.help'
defaultMessage='Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost.'
values={{
outgoingOauthConnections: (
<ExternalLink
href={DeveloperLinks.SETUP_OAUTH2}
location='installed_outgoing_oauth_connections'
>
<FormattedMessage
id='installed_outgoing_oauth_connections.help.outgoingOauthConnections'
defaultMessage='Outgoing OAuth Connections'
/>
</ExternalLink>
),
}}
/>
}
emptyText={
<FormattedMessage
id='installed_outgoing_oauth_connections.empty'
defaultMessage='No Outgoing OAuth Connections found'
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
id='installed_outgoing_oauth_connections.emptySearch'
defaultMessage='No Outgoing OAuth Connections match {searchTerm}'
/>
}
searchPlaceholder={intl.formatMessage({id: 'installed_outgoing_oauth_connections.search', defaultMessage: 'Search Outgoing OAuth Connections'})}
loading={loading}
{...childProps}
>
{(filter: string) => {
const children = outgoingOauthConnections(filter);
return [children, children.length > 0];
}}
</BackstageList>
);
};
export default InstalledOutgoingOAuthConnections;

Просмотреть файл

@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react';
import React from 'react';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import OAuthConnectionAudienceInput from 'components/integrations/outgoing_oauth_connections/oauth_connection_audience_input';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput', () => {
const connection: OutgoingOAuthConnection = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testConnection',
client_secret: '',
client_id: 'clientid',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
grant_type: 'client_credentials',
oauth_token_url: 'https://token.com',
audiences: ['https://aud.com/api'],
};
const baseProps: React.ComponentProps<typeof OAuthConnectionAudienceInput> = {
value: '',
onChange: jest.fn(),
placeholder: '',
};
const team = TestHelper.getTeamMock({name: 'test'});
const stateFromOAuthConnections = (connections: Record<string, OutgoingOAuthConnection>) => {
return {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
teams: {
teams: {
[team.id]: team,
},
currentTeamId: team.id,
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections: connections,
},
},
};
};
test('should match snapshot with no existing connections', async () => {
const props = {...baseProps};
const state = stateFromOAuthConnections({});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot with existing connections', async () => {
const props = {...baseProps};
const state = stateFromOAuthConnections({[connection.id]: connection});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot when typed in value matches a configured audience', async () => {
const props = {...baseProps, value: 'https://aud.com/api'};
const state = stateFromOAuthConnections({[connection.id]: connection});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot when typed in value does not have an exact match', async () => {
const props = {...baseProps, value: 'https://aud.com/api/no_match'};
const state = stateFromOAuthConnections({[connection.id]: connection});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot when an audience url with a wildcard is configured, and typed in value starts with configured audience url', async () => {
const props = {...baseProps, value: 'https://aud.com/api/it_matches'};
const state = stateFromOAuthConnections({[connection.id]: {...connection, audiences: ['https://aud.com/api/*']}});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
});

Просмотреть файл

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {OauthIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {
getOutgoingOAuthConnectionsForAudience as fetchOutgoingOAuthConnectionsForAudience,
getOutgoingOAuthConnections as fetchOutgoingOAuthConnections,
} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
type Props = {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder: string;
}
const OAuthConnectionAudienceInput = (props: Props) => {
const mounted = useRef(false);
const [matchedConnection, setMatchingOAuthConnection] = useState<OutgoingOAuthConnection | null>(null);
const [loadingAudienceMatch, setLoadingAudienceMatch] = useState(false);
const oauthConnections = useSelector(getOutgoingOAuthConnections);
const oauthConnectionsEnabled = useSelector(getConfig).EnableOutgoingOAuthConnections === 'true';
const teamId = useSelector(getCurrentTeamId);
const dispatch = useDispatch();
const matchConnectionsOnInput = useCallback(async (inputValue: string) => {
const res = await dispatch(fetchOutgoingOAuthConnectionsForAudience(teamId, inputValue));
setLoadingAudienceMatch(false);
if (res.data && res.data.length) {
setMatchingOAuthConnection(res.data[0]);
} else {
setMatchingOAuthConnection(null);
}
}, [dispatch, teamId]);
const debouncedMatchConnections = useMemo(() => {
return debounce((inputValue: string) => matchConnectionsOnInput(inputValue), 1000);
}, [matchConnectionsOnInput]);
useEffect(() => {
if (mounted.current) {
return;
}
mounted.current = true;
if (oauthConnectionsEnabled) {
dispatch(fetchOutgoingOAuthConnections(teamId));
if (props.value) {
setLoadingAudienceMatch(true);
matchConnectionsOnInput(props.value);
}
}
}, [oauthConnectionsEnabled, props.value, teamId, matchConnectionsOnInput, dispatch, mounted]);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
props.onChange(e);
if (oauthConnectionsEnabled) {
setLoadingAudienceMatch(true);
debouncedMatchConnections(e.target.value);
}
};
const connections = Object.values(oauthConnections);
const input = (
<input
autoComplete='off'
id='url'
maxLength={1024}
className='form-control'
value={props.value}
onChange={onChange}
placeholder={props.placeholder}
/>
);
if (!connections.length) {
return input;
}
let oauthMessage: React.ReactNode;
if (loadingAudienceMatch) {
oauthMessage = (
<span>
<LoadingSpinner/>
</span>
);
} else if (matchedConnection) {
oauthMessage = (
<>
<span>
<OauthIcon
size={20}
/>
</span>
<span className='outgoing-oauth-audience-match-message'>
<FormattedMessage
id='add_outgoing_oauth_connection.connected'
defaultMessage='Connected to "{connectionName}"'
values={{
connectionName: matchedConnection.name,
}}
/>
</span>
</>
);
} else {
oauthMessage = (
<>
<span>
<InformationOutlineIcon
size={20}
/>
</span>
<span className='outgoing-oauth-audience-match-message'>
<FormattedMessage
id='add_outgoing_oauth_connection.not_connected'
defaultMessage='Not linked to an OAuth connection'
/>
</span>
</>
);
}
return (
<>
{input}
<div className='outgoing-oauth-audience-match-message-container'>
{oauthMessage}
</div>
</>
);
};
export default OAuthConnectionAudienceInput;