diff --git a/ui/src/__tests__/spec/tests/domain.spec.js b/ui/src/__tests__/spec/tests/domain.spec.js index 5ee604aad00..198f4e42c29 100644 --- a/ui/src/__tests__/spec/tests/domain.spec.js +++ b/ui/src/__tests__/spec/tests/domain.spec.js @@ -62,6 +62,39 @@ describe('Domain', () => { ); }); + it('should successfully add and clear domain slack channel', async () => { + await browser.newUser(); + await browser.url(`/`); + await expect(browser).toHaveUrl(expect.stringContaining('athenz')); + + let testDomain = await $('a*=athenz.dev.functional-test'); + await browser.waitUntil(async () => await testDomain.isClickable()); + await testDomain.click(); + + // expand domain details + let expand = await $( + `.//*[local-name()="svg" and @data-wdio="domain-details-expand-icon"]` + ); + await expand.click(); + + // click add business service + let addSlackChannel = await $('a[data-testid="add-slack-channel"]'); + await browser.waitUntil( + async () => await addSlackChannel.isClickable() + ); + await addSlackChannel.click(); + + let randomInt = Math.floor(Math.random() * 100); // random number to append to slack channel name + let slackChannelName = 'slack-channel-' + randomInt; + let slackChannelInput = await $('input[name="slack-channel-input"]'); + await slackChannelInput.addValue(slackChannelName); + let submitButton = await $('button*=Submit'); + await submitButton.click(); + await expect(addSlackChannel).toHaveText( + expect.stringContaining(slackChannelName) + ); + }); + it(TEST_ADD_BUSINESS_SERVICE_INPUT_PRESERVES_CONTENTS_ON_BLUR, async () => { currentTest = TEST_ADD_BUSINESS_SERVICE_INPUT_PRESERVES_CONTENTS_ON_BLUR; diff --git a/ui/src/components/header/DomainDetails.js b/ui/src/components/header/DomainDetails.js index 9823ea2d710..161779bad5d 100644 --- a/ui/src/components/header/DomainDetails.js +++ b/ui/src/components/header/DomainDetails.js @@ -47,6 +47,7 @@ import Icon from '../denali/icons/Icon'; import AddPoc from '../member/AddPoc'; import { selectAllUsers } from '../../redux/selectors/user'; import AddEnvironmentModal from '../modal/AddEnvironmentModal'; +import AddSlackChannelModal from '../modal/AddSlackChannelModal'; const DomainSectionDiv = styled.div` margin: 20px 0; @@ -127,6 +128,7 @@ class DomainDetails extends React.Component { ), expandedDomain: false, environmentName: this.props.domainDetails.environment || 'add', + slackChannel: this.props.domainDetails.slackChannel || 'add', }; this.showError = this.showError.bind(this); this.closeModal = this.closeModal.bind(this); @@ -197,6 +199,41 @@ class DomainDetails extends React.Component { } } + onClickSlackChannel() { + this.setState({ + showSlackChannelModal: true, + }); + } + + onSlackChannelUpdateSuccessCb(slackChannelName) { + let newState = { + showSlackChannelModal: false, + showSuccess: true, + }; + + if (!!!slackChannelName) { + slackChannelName = 'add'; + } + newState.slackChannel = slackChannelName; + newState.successMessage = 'Successfully updated Slack channel'; + this.setState(newState); + setTimeout( + () => + this.setState({ + showSuccess: false, + }), + MODAL_TIME_OUT + 1000 + ); + } + + onClickSlackChannelCancel() { + this.setState({ + showSlackChannelModal: false, + errorMessage: null, + errorMessageForModal: '', + }); + } + onClickPointOfContactCancel() { this.setState({ showPoc: false, @@ -505,6 +542,7 @@ class DomainDetails extends React.Component { this.state.poc, 'security-owner' ); + let onClickSlackChannel = this.onClickSlackChannel.bind(this); let contactType; let pocName; let openPocModal; @@ -553,6 +591,24 @@ class DomainDetails extends React.Component { '' ); + let slackChannelModal = this.state.showSlackChannelModal ? ( + + ) : ( + '' + ); + return ( @@ -675,6 +731,7 @@ class DomainDetails extends React.Component { /> ) : null} {environmentModal} + {slackChannelModal} {this.state.expandedDomain ? ( @@ -735,6 +792,21 @@ class DomainDetails extends React.Component { ENVIRONMENT + + + + {this.state.slackChannel} + + + SLACK CHANNEL + ) : null} diff --git a/ui/src/components/modal/AddSlackChannelModal.js b/ui/src/components/modal/AddSlackChannelModal.js new file mode 100644 index 00000000000..23b4bf79344 --- /dev/null +++ b/ui/src/components/modal/AddSlackChannelModal.js @@ -0,0 +1,177 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { connect } from 'react-redux'; +import AddModal from '../modal/AddModal'; +import styled from '@emotion/styled'; +import InputLabel from '../denali/InputLabel'; +import { colors } from '../denali/styles'; +import { selectAllUsers } from '../../redux/selectors/user'; +import RequestUtils from '../utils/RequestUtils'; +import Input from '../denali/Input'; +import RegexUtils from '../utils/RegexUtils'; + +const SectionsDiv = styled.div` + width: 760px; + text-align: left; + background-color: ${colors.white}; +`; + +const SectionDiv = styled.div` + align-items: flex-start; + display: flex; + flex-flow: row nowrap; + padding: 10px 30px; +`; + +const ContentDiv = styled.div` + flex: 1 1; + display: flex; + flex-flow: row nowrap; +`; + +const StyledInputLabel = styled(InputLabel)` + flex-basis: 32%; + font-weight: 600; + line-height: 36px; +`; + +const StyledInput = styled(Input)` + flex-basis: 80%; +`; + +class AddSlackChannelModal extends React.Component { + constructor(props) { + super(props); + this.api = props.api; + this.onSubmit = this.onSubmit.bind(this); + let slackChannelName = + this.props.slackChannelName === 'add' + ? '' + : this.props.slackChannelName; + this.state = { + showModal: this.props.isOpen, + slackChannelName: slackChannelName, + }; + } + + onSubmit() { + if (!!this.state.slackChannelName) { + if ( + !RegexUtils.validate( + this.state.slackChannelName, + /^[a-z0-9-_]{1}[a-z0-9-_]{0,79}$/ + ) + ) { + this.setState({ + errorMessage: + 'Slack channel name is invalid. It should be between 1-80 characters long and can only contain lowercase letters, numbers, hyphens and underscores.', + }); + return; + } + } + + let meta = { + slackChannel: this.state.slackChannelName, + }; + let domainName = this.props.domain; + let auditMsg = `Updating Slack channel ${this.state.slackChannelName} for domain ${this.props.domain}`; + let csrf = this.props.csrf; + this.api + .putMeta(domainName, domainName, meta, auditMsg, csrf, 'domain') + .then(() => { + this.setState({ + showModal: false, + }); + this.props.onSlackChannelUpdateSuccessCb( + this.state.slackChannelName + ); + }) + .catch((err) => { + this.setState({ + errorMessage: RequestUtils.xhrErrorCheckHelper(err), + }); + }); + } + + inputChanged(key, evt) { + let value = ''; + if (evt.target) { + value = evt.target.value; + } else { + value = evt ? evt : ''; + } + this.setState({ [key]: value }); + } + + render() { + let memberLabel = 'Slack Channel Name'; + let title = + 'Update slack channel to receive notifications for ' + + this.props.domain; + let sections = ( + + + + {memberLabel} + + + + + + + ); + + return ( +
+ +
+ ); + } +} + +const mapStateToProps = (state, props) => { + return { + ...props, + userList: selectAllUsers(state), + }; +}; +const mapDispatchToProps = (dispatch) => ({}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(AddSlackChannelModal); diff --git a/ui/src/config/msd.json b/ui/src/config/msd.json index 605046e6ccc..73bb965375c 100644 --- a/ui/src/config/msd.json +++ b/ui/src/config/msd.json @@ -3531,4 +3531,4 @@ "expected": "OK" } ] -} \ No newline at end of file +} diff --git a/ui/src/config/ums.json b/ui/src/config/ums.json index 737aa3daace..36a5a4b1297 100644 --- a/ui/src/config/ums.json +++ b/ui/src/config/ums.json @@ -2783,4 +2783,4 @@ } } ] -} \ No newline at end of file +} diff --git a/ui/src/config/zms.json b/ui/src/config/zms.json index 1dba066077d..09985b45057 100644 --- a/ui/src/config/zms.json +++ b/ui/src/config/zms.json @@ -12348,4 +12348,4 @@ "expected": "OK" } ] -} \ No newline at end of file +} diff --git a/ui/src/config/zts.json b/ui/src/config/zts.json index cf9c2fb59b5..2950a759ec9 100644 --- a/ui/src/config/zts.json +++ b/ui/src/config/zts.json @@ -4551,4 +4551,4 @@ "expected": "OK" } ] -} \ No newline at end of file +} diff --git a/ui/src/server/constants.js b/ui/src/server/constants.js new file mode 100644 index 00000000000..5b400eedc5d --- /dev/null +++ b/ui/src/server/constants.js @@ -0,0 +1,21 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const validationPatterns = { + domainSlackChannel: /^[a-z0-9-_]{1}[a-z0-9-_]{0,79}$/, +}; + +module.exports = validationPatterns; diff --git a/ui/src/server/handlers/api.js b/ui/src/server/handlers/api.js index 6525148f6c0..f7d69bbaee9 100644 --- a/ui/src/server/handlers/api.js +++ b/ui/src/server/handlers/api.js @@ -22,6 +22,7 @@ const apiUtils = require('../utils/apiUtils'); const debug = require('debug')('AthenzUI:server:handlers:api'); const cytoscape = require('cytoscape'); let dagre = require('cytoscape-dagre'); +const validationRegex = require('../constants'); let appConfig = {}; @@ -51,6 +52,22 @@ const responseHandler = function (err, data) { } }; +const validateFields = (value, field) => { + let regex = validationRegex.hasOwnProperty(field) + ? validationRegex[field] + : null; + if (!regex) { + console.log('Missing regex for ' + field); + } + + if (regex.test(value)) { + return true; + } + + console.log('Validation error for ' + field + ': ' + value); + return false; +}; + const deleteInstanceZts = ( provider, domainName, @@ -1501,18 +1518,40 @@ Fetchr.registerService({ break; } case 'domain': { - req.clients.zms.putDomainMeta( - { - name: params.domainName, - auditRef: params.auditRef, - detail: params.detail, - }, - responseHandler.bind({ - caller: 'putDomainMeta', - callback, - req, - }) - ); + if ( + params.detail.slackChannel && + !validateFields( + params.detail.slackChannel, + 'domainSlackChannel' + ) + ) { + let validationErr = { + status: '400', + message: { + message: 'Invalid Slack Channel format', + }, + }; + callback( + errorHandler.fetcherError( + validationErr, + 'validationError' + ) + ); + } else { + req.clients.zms.putDomainMeta( + { + name: params.domainName, + auditRef: params.auditRef, + detail: params.detail, + }, + responseHandler.bind({ + caller: 'putDomainMeta', + callback, + req, + }) + ); + } + break; } case 'policy': {