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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
3f6c94cfc3
Коммит
4e071e861c
@@ -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;
|
||||
Ссылка в новой задаче
Block a user