diff --git a/group_vars/all/main.yml b/group_vars/all/main.yml
index 521b079b6b93cdb120d0966c44700e1284cd13f4..84d605b7475c546285a802b78307b05764f975f6 100644
--- a/group_vars/all/main.yml
+++ b/group_vars/all/main.yml
@@ -2,6 +2,11 @@
 
 dslproxy: "<CHANGE_ME:hostname>"
 
+# TheHive Button plugin
+THEHIVE_URL: "https://hive.gn4-3-wp8-soc.sunet.se/"
+THEHIVE_API_KEY: "5LymseWiurZBrQN8Kqp8O+9KniTL5cE0"
+THEHIVE_OWNER: "admin"
+
 soctools_netname: "soctoolsnet"
 
 repo: gn43-dsl
diff --git a/roles/build/tasks/odfekibana.yml b/roles/build/tasks/odfekibana.yml
index 8ca53578baceacba76726bece7c1dfbe7491c917..8e1980a7680f5f23b7370d61dba057e457430291 100644
--- a/roles/build/tasks/odfekibana.yml
+++ b/roles/build/tasks/odfekibana.yml
@@ -5,6 +5,9 @@
     src: odfekibana/Dockerfile-kibana.j2
     dest: "{{role_path}}/files/kibanaDockerfile"
 
+- name: Copy tools to build path
+  command: "cp -av {{role_path}}/templates/odfekibana/thehive_button/ {{role_path}}/files/thehive_button/"
+
 - name: Build kibana image
   command: docker build -t {{repo}}/kibana:{{version}}{{suffix}} -f {{role_path}}/files/kibanaDockerfile {{role_path}}/files
 
diff --git a/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2 b/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2
index 8f72fd770ba85b5b54962fe96dd9bb5bbd898069..ee69568d34d6fe879de100414f8f3b96b2252d7f 100644
--- a/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2
+++ b/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2
@@ -11,5 +11,8 @@ RUN for PLUGIN in \
     https://d3g5vo6xdbdb9a.cloudfront.net/downloads/kibana-plugins/opendistro-index-management/opendistro_index_management_kibana-{{odfeplugin_version}}.zip; \
     do bin/kibana-plugin install --allow-root ${PLUGIN}; done
 
+ADD thehive_button /usr/share/kibana/plugins/thehive_button
+RUN chown -R kibana:kibana /usr/share/kibana/plugins/thehive_button
+
 USER kibana
 
diff --git a/roles/build/templates/odfekibana/thehive_button/.eslintrc b/roles/build/templates/odfekibana/thehive_button/.eslintrc
new file mode 100644
index 0000000000000000000000000000000000000000..64eba86220ec489c9c364e9a443941d14a8d3b16
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/.eslintrc
@@ -0,0 +1,7 @@
+---
+extends: "@elastic/kibana"
+
+settings:
+  import/resolver:
+    '@elastic/eslint-import-resolver-kibana':
+      rootPackageName: 'thehive_button'
diff --git a/roles/build/templates/odfekibana/thehive_button/.kibana-plugin-helpers.json b/roles/build/templates/odfekibana/thehive_button/.kibana-plugin-helpers.json
new file mode 100644
index 0000000000000000000000000000000000000000..2c63c0851048d8f7bff41ecf0f8cee05f52fd120
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/.kibana-plugin-helpers.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/roles/build/templates/odfekibana/thehive_button/index.js b/roles/build/templates/odfekibana/thehive_button/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..fa69c75c30d7ee40f8d7089d6debd6cf69c8d402
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/index.js
@@ -0,0 +1,19 @@
+import newCaseRoute from './server/routes/newcase';
+
+export default function (kibana) {
+  return new kibana.Plugin({
+    require: [], //['elasticsearch'],
+    name: 'thehive_button',
+    uiExports: {
+      visTypes: [
+        'plugins/thehive_button/main',
+      ],
+    },
+
+    init(server, options) { // eslint-disable-line no-unused-vars
+      // Add server routes and initialize the plugin here
+      newCaseRoute(server);
+    }
+  });
+}
+
diff --git a/roles/build/templates/odfekibana/thehive_button/package.json b/roles/build/templates/odfekibana/thehive_button/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..4290a7869d461c81f8793593dd2282141903c75d
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/package.json
@@ -0,0 +1,35 @@
+{
+  "name": "thehive_button",
+  "version": "0.3.0",
+  "description": "Visualisation plugin which creates a simple button to create a new case in The Hive.",
+  "main": "index.js",
+  "kibana": {
+    "version": "7.4.2"
+  },
+  "scripts": {
+    "lint": "eslint .",
+    "start": "plugin-helpers start",
+    "build": "plugin-helpers build"
+  },
+  "dependencies": {
+    "request": "^2.88.0",
+    "@elastic/eui": "10.4.2",
+    "react": "^16.8.0"
+  },
+  "devDependencies": {
+    "@elastic/eslint-config-kibana": "link:../../packages/eslint-config-kibana",
+    "@elastic/eslint-import-resolver-kibana": "link:../../packages/kbn-eslint-import-resolver-kibana",
+    "@kbn/plugin-helpers": "link:../../packages/kbn-plugin-helpers",
+    "babel-eslint": "^9.0.0",
+    "eslint": "^5.6.0",
+    "eslint-plugin-babel": "^5.2.0",
+    "eslint-plugin-import": "^2.14.0",
+    "eslint-plugin-jest": "^21.26.2",
+    "eslint-plugin-jsx-a11y": "^6.1.2",
+    "eslint-plugin-mocha": "^5.2.0",
+    "eslint-plugin-no-unsanitized": "^3.0.2",
+    "eslint-plugin-prefer-object-spread": "^1.2.1",
+    "eslint-plugin-react": "^7.11.1",
+    "expect.js": "^0.3.1"
+  }
+}
diff --git a/roles/build/templates/odfekibana/thehive_button/public/main.js b/roles/build/templates/odfekibana/thehive_button/public/main.js
new file mode 100644
index 0000000000000000000000000000000000000000..363156967e0684554abdd0580280a9e772a62c93
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/public/main.js
@@ -0,0 +1,128 @@
+//import './vis.less';
+import { THEHIVE_API_KEY, THEHIVE_URL, THEHIVE_OWNER } from './env';
+//import { VisController } from './vis_controller';
+import { TheHiveButtonVisComponent } from './vis_controller';
+
+import { VisFactoryProvider } from 'ui/vis/vis_factory';
+import { VisTypesRegistryProvider } from 'ui/registry/vis_types';
+import { Schemas } from 'ui/vis/editors/default/schemas';
+//import { Status } from 'ui/vis/update_status';
+import { DefaultEditorSize } from 'ui/vis/editor_size';
+
+import optionsTemplate from './options_template.html';
+
+/*
+TODO: It would be better to compose options tab of EUI React elements,
+but this probably needs at least kibana 7.4.
+See this for example of a custom editor:
+  https://github.com/elastic/kibana/tree/master/src/plugins/vis_type_markdown/public 
+
+import React from 'react';
+import {
+  EuiForm,
+  EuiFormRow,
+  EuiFieldText,
+} from '@elastic/eui';
+
+import { VisOptionsProps } from 'src/plugins/vis_default_editor/public';
+
+function optionsEditor({ stateParams, setValue }) {
+  return (
+  <EuiForm>
+    <EuiFormRow label="Base URL of The Hive">
+      <EuiFieldText value={stateParams.url} />
+    </EuiFormRow>
+    <EuiFormRow label="API key">
+      <EuiFieldText value={stateParams.apikey} />
+    </EuiFormRow>
+    <EuiFormRow label="Username" helpText="Used as the owner of cases created from here">
+      <EuiFieldText value={stateParams.owner} />
+    </EuiFormRow>
+  </EuiForm>
+);
+*/
+
+function TheHiveButtonVisProvider(Private) {
+  const VisFactory = Private(VisFactoryProvider);
+
+  console.log("default URL:", THEHIVE_URL);
+  console.log("default API key:", THEHIVE_API_KEY);
+
+  return VisFactory.createReactVisualization({
+    name: 'thehive_button',
+    title: 'The Hive Case',
+    icon: 'alert',
+    description: 'A button to create a new Case in The Hive.',
+    //requiresUpdateStatus: [Status.PARAMS, Status.RESIZE, Status.UI_STATE],
+    visConfig: {
+      component: TheHiveButtonVisComponent,
+      defaults: {
+        // add default parameters
+        url: THEHIVE_URL,
+        apikey: THEHIVE_API_KEY,
+        owner: THEHIVE_OWNER,
+      }
+    },
+//    editor: optionsEditor,
+     editor: 'default',
+    editorConfig: {
+      optionsTemplate: optionsTemplate,
+      defaultSize: DefaultEditorSize.MEDIUM,
+      //enableAutoApply: true,
+      schemas: new Schemas([
+        // TODO all this (metric and bucket "terms") could be pre-defind
+        // (just a selection of fields to read Observables from would be nice)
+        // But I don't know how and where to pass the values manually.
+        /* Allowed params (from /src/legacy/ui/public/vis/editors/default/schemas.d.ts)
+            aggFilter: string | string[];
+            editor: boolean | string;
+            group: AggGroupNames;
+            max: number;
+            min: number;
+            name: string;
+            params: AggParam[];
+            title: string;
+        */
+        {
+          group: 'metrics',
+          name: 'metric',
+          title: "Metric (not used, ignore this)",
+          min: 1,
+          max: 1,
+          aggFilter: 'count',
+          defaults: [
+            {
+              type: 'count',
+              schema: 'metric',
+            },
+          ],
+          editor: '<div class="hintbox"><i class="fa fa-danger text-info"></i> Some metric must be defined here, but it\'s setting is irrelevant. Just go to "Buckets" below and set up the field to get Observbles from.</div>'
+        },
+        {
+          group: 'buckets',
+          name: 'group',
+          title: 'Observables',
+          min: 0,
+          //max: 1,
+          aggFilter: ['terms'],
+          defaults: [
+            {
+              type: 'terms',
+              schema: 'group',
+              field: 'ip',
+              size: 1000,
+              orderBy: 'alphabetical', 
+              order: 'ascending',
+            }
+          ]
+        },
+      ]),
+    },
+    //requestHandler: 'courier', // default
+    //responseHandler: 'default', // return data as a table, see https://www.elastic.co/guide/en/kibana/7.2/development-visualization-response-handlers.html
+  });
+}
+
+// register the provider with the visTypes registry
+VisTypesRegistryProvider.register(TheHiveButtonVisProvider);
+
diff --git a/roles/build/templates/odfekibana/thehive_button/public/options_template.html b/roles/build/templates/odfekibana/thehive_button/public/options_template.html
new file mode 100644
index 0000000000000000000000000000000000000000..ef996577786150282c2ffb0d28652a3d1712842b
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/public/options_template.html
@@ -0,0 +1,8 @@
+<div class="form-group">
+  <p><label>Base URL of The Hive</label>
+  <input ng-model="editorState.params.url" class=form-control /></p>
+  <p><label>API key</label>
+  <input ng-model="editorState.params.apikey" class=form-control /></p>
+  <p><label>User name to use as the owner of cases created from here</label>
+  <input ng-model="editorState.params.owner" class=form-control /></p>
+</div>
diff --git a/roles/build/templates/odfekibana/thehive_button/public/vis.less b/roles/build/templates/odfekibana/thehive_button/public/vis.less
new file mode 100644
index 0000000000000000000000000000000000000000..b6f887afaef57a7674a0d0f06ee6f821a0fc015e
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/public/vis.less
@@ -0,0 +1,3 @@
+.myvis-container-div {
+  padding: 1em;
+}
diff --git a/roles/build/templates/odfekibana/thehive_button/public/vis_controller.js b/roles/build/templates/odfekibana/thehive_button/public/vis_controller.js
new file mode 100644
index 0000000000000000000000000000000000000000..3018390b86d22dc8f9fa0a62c2164d98963a1047
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/public/vis_controller.js
@@ -0,0 +1,609 @@
+//import { Status } from 'ui/vis/update_status';
+import chrome from 'ui/chrome';
+import { toastNotifications } from 'ui/notify';
+//import vis_template from './vis_template.html';
+
+import React, { Component } from 'react';
+import {
+  EuiButton,
+  EuiButtonEmpty,
+  EuiModal,
+  EuiModalBody,
+  EuiModalFooter,
+  EuiModalHeader,
+  EuiModalHeaderTitle,
+  EuiOverlayMask,
+  EuiTitle,
+  EuiFlexGroup,
+  EuiFlexItem,
+  EuiForm,
+  EuiFormRow,
+  EuiFieldText,
+  EuiTextArea,
+  EuiSuperSelect,
+  EuiBasicTable,
+  EuiCheckbox,
+  makeId,
+} from '@elastic/eui';
+
+// TODO:
+// - Explicit reset button
+// - Reset when data (query) changes
+// - Hide modal when clicked outside
+
+
+// ********** React components **********
+
+// Main React component - the root of visualization
+export class TheHiveButtonVisComponent extends Component {
+  render() {
+    //console.log(this.props);
+    let ips = [];
+    if (this.props.visData) {
+      const first_col_id = this.props.visData.columns[0].id;
+      ips = this.props.visData.rows.map(row => row[first_col_id]);
+      //console.log(ips);
+    }
+    return (
+      <div>
+        <NewCaseButton params={this.props.vis.params} observables={ips} />
+      </div>
+    );
+  }
+
+  componentDidMount() {
+    this.props.renderComplete();
+  }
+
+  componentDidUpdate() {
+    this.props.renderComplete();
+  }
+}
+
+// Button to show the pop-up window (modal)
+// Props:
+//  .params - visualization parameters (from vis.params)
+//  .observables - list of IP addrsses or other observables to add to the Case
+class NewCaseButton extends Component {
+
+  constructor(props) {
+    super(props);
+    const n_obs = props.observables.length;
+    
+    // initial state of form fields - used to reset form
+    this.initial_case_state = {
+      // Case parameters
+      title: "",
+      description: "\n\n--\nCreated from Kibana",
+      severity: "2", // medium
+      tlp: "2", // amber
+      tags: [], // TODO (not implemented yet)
+      // An array of values for each editable part of observables, prefilled with default values
+      obsDescrs: new Array(n_obs).fill(""),
+      obsTLPs: new Array(n_obs).fill(2), // TODO (not implemented yet)
+      obsIOCs: new Array(n_obs).fill(false),
+      obsTags: new Array(n_obs).fill([]), // TODO (not implemented yet)
+      obsSelected: new Array(), // list of indices of selected observables
+    }
+    
+    // The complete state is here, so it's kept even when modal is closed
+    this.state = {
+      isModalVisible: false,
+      ...this.initial_case_state, // TODO isn't deep copy needed here (and in reset_form below)?
+    }
+
+    // Each handler function in a class (method) must be "binded" this way
+    this.closeModal = this.closeModal.bind(this);
+    this.showModal = this.showModal.bind(this);
+
+    this.onTitleChange = this.onTitleChange.bind(this);
+    this.onSeverityChange = this.onSeverityChange.bind(this);
+    this.onTLPChange = this.onTLPChange.bind(this);
+    this.onDescriptionChange = this.onDescriptionChange.bind(this);
+    
+    this.onObsDescrChange = this.onObsDescrChange.bind(this);
+    this.onObsTLPChange = this.onObsTLPChange.bind(this);
+    this.onObsIOCChange = this.onObsIOCChange.bind(this);
+    this.onObsTagsChange = this.onObsTagsChange.bind(this);
+    this.onObsSelectionChange = this.onObsSelectionChange.bind(this);
+    
+    this.submitCase = this.submitCase.bind(this);
+  }
+
+  resetForm() {
+    this.setState(this.initial_case_state);
+  }
+
+  closeModal() {
+    this.setState({ isModalVisible: false });
+  }
+
+  showModal() {
+    this.setState({ isModalVisible: true });
+  }
+
+  // Event handlers for change of case parameter
+  onTitleChange(evt) {
+    this.setState({title: evt.target.value});
+  }
+  onSeverityChange(value) {
+    this.setState({severity: value});
+  }
+  onTLPChange(value) {
+    this.setState({tlp: value});
+  }
+  onDescriptionChange(evt) {
+    this.setState({description: evt.target.value});
+  }
+
+  // Event handlers for change of observable parameters
+  onObsDescrChange(evt, rowindex) {
+    const val = evt.target.value;
+    this.setState((state, props) => {
+      // Copy old obsDescrs, update the corresponding item and pass as new state
+      let descrs = [...state.obsDescrs];
+      descrs[rowindex] = val;
+      return {obsDescrs: descrs};
+    });
+  }
+  onObsTLPChange(evt, rowindex) { // TODO
+    const val = evt.target.value;
+    this.setState((state, props) => {
+      // Copy old obsTLPs, update the corresponding item and pass as new state
+      let tlps = [...state.obsTLPs];
+      tlps[rowindex] = val;
+      return {obsTLPs: tlps};
+    });
+  }
+  onObsIOCChange(evt, rowindex) {
+    const val = evt.target.checked;
+    this.setState((state, props) => {
+      // Copy old obsIOCs, update the corresponding item and pass as new state
+      let iocs = [...state.obsIOCs];
+      iocs[rowindex] = val;
+      return {obsIOCs: iocs};
+    });
+  }
+  onObsTagsChange(evt, rowindex) { // TODO
+    const val = evt.target.value;
+    this.setState((state, props) => {
+      // Copy old obsTags, update the corresponding item and pass as new state
+      let tags = [...state.obsTags];
+      tags[rowindex] = val;
+      return {obsTags: tags};
+    });
+  }
+  
+  // Event handler for row (de)selection
+  onObsSelectionChange(selectedItems) {
+    // Extract indices from the items and store them into state
+    const selectedIndices = selectedItems.map(item => item.i);
+    this.setState({obsSelected: selectedIndices});
+  }
+  
+  // Submit case button handler
+  submitCase(evt) {
+    const params = this.props.params;
+    
+    // Get case parameters
+    const title = this.state.title;
+    const descr = this.state.description;
+    const severity = parseInt(this.state.severity);
+    const start_date = null;
+    const owner = params.owner;
+    const flag = false;
+    const tlp = parseInt(this.state.tlp);
+    const tags = this.state.tags;
+    
+    if (!title) {
+      toastNotifications.addDanger("Title can't be empty");
+      return;
+    }
+    
+    // Get list of selected observables and their params
+    let observables = [];
+    let selectionIndices = [...this.state.obsSelected]; // make a copy
+    selectionIndices.sort();
+    for (let i = 0; i < selectionIndices.length; i++) {
+      const j = selectionIndices[i]; // index of a selected obs. in the list of all observables
+      // fill in observable definition according to model at
+      // https://github.com/TheHive-Project/TheHiveDocs/blob/master/api/artifact.md
+      const obs = {
+        dataType: 'ip',
+        data: this.props.observables[j],
+        message: this.state.obsDescrs[j],
+        tlp: this.state.obsTLPs[j],
+        ioc: this.state.obsIOCs[j],
+        tags: this.state.obsTags[j],
+      };
+      observables.push(obs);
+    }
+    //console.log("Selected observables:", observables);
+    
+    // All data cached in local variables - reset the form fields
+    this.resetForm();
+    
+    // Submit request to create the case, handle response
+    // TODO rewite to await
+    createHiveCase(params.url, params.apikey, title, descr, severity, start_date, owner, flag, tlp, tags)
+      .then((resp) => {
+        if ('error' in resp) {
+          // Error contacting The Hive
+          console.error("TheHiveButton: ERROR when trying to create new case:", resp.error);
+          toastNotifications.addDanger("ERROR: " + resp.error);
+          return;
+        }
+
+        console.log("TheHiveButton: Case created:", resp);
+        const case_id = resp.id;
+        const case_url = params.url + "index.html#/case/" + case_id + "/details";
+        
+        // Show notification
+        let obs_text;
+        if (observables.length > 0) {
+          obs_text = "Adding " + observables.length + " observables in background ...";
+        }
+        else {
+          obs_text = "(no observables added)";
+        }
+        toastNotifications.add({
+          title: "Case created",
+          color: "success",
+          iconType: "checkInCircleFilled",
+          text: (
+            <div>
+              <p><b><a href={case_url} target="_blank">Edit the new Case</a></b></p>
+              <p>{obs_text}</p>
+            </div>
+          ),
+        });
+         
+        // Open a new window with the case in The Hive
+        // (adding observables may take some time, so the case is opened first;
+        //  The Hive web is dynamic so the observables appear as they are added)
+        window.open(case_url, '_blank');
+        
+        if (observables.length == 0)
+          return;
+        
+        // Submit request to add observables
+        console.log("TheHiveButton: adding " + observables.length + " observables ...");
+        addCaseObservables(params.url, params.apikey, case_id, observables)
+          .then((resp) => {
+            if ('error' in resp) {
+              console.error("TheHiveButton: ERROR when trying to add observables: " + resp.error);
+              toastNotifications.addDanger("ERROR when trying to add observables: " + resp.error);
+            }
+            else {
+              console.log("TheHiveButton: Done, observables added.");
+              toastNotifications.add("Done, observables added.");
+            }
+          }); // addObservables().then() func
+      }); // createHiveCase.then() func
+    
+    // Close the popup window
+    this.closeModal();
+  }
+
+  // Render function
+  render() {
+    let modal;
+    if (this.state.isModalVisible) {
+      modal = <ModalContent
+        close={this.closeModal}
+        observables={this.props.observables}
+        // form state
+        title={this.state.title}
+        description={this.state.description}
+        severity={this.state.severity}
+        tlp={this.state.tlp}
+        tags={this.state.tags}
+        obsDescrs={this.state.obsDescrs}
+        obsTLPs={this.state.obsTLPs}
+        obsIOCs={this.state.obsIOCs}
+        obsTags={this.state.obsTags}
+        obsSelected={this.state.obsSelected}
+        // event handlers
+        onTitleChange={this.onTitleChange}
+        onSeverityChange={this.onSeverityChange}
+        onTLPChange={this.onTLPChange}
+        onDescriptionChange={this.onDescriptionChange}
+        onObsDescrChange={this.onObsDescrChange}
+        onObsTLPChange={this.onObsTLPChange}
+        onObsIOCChange={this.onObsIOCChange}
+        onObsTagsChange={this.onObsTagsChange}
+        onObsSelectionChange={this.onObsSelectionChange}
+        submitCase={this.submitCase}
+      />;
+    }
+    return (
+      <div>
+        <EuiButton fill iconType="alert" color="danger" onClick={this.showModal}>Create new Case ...</EuiButton>
+        {modal}
+      </div>
+    );
+  }
+}
+
+
+// The popup window with a form
+class ModalContent extends Component {
+  constructor(props) {
+    super(props);
+    // No state here, everything is in the parent class (NewCaseButton)
+    
+    // "Select" options
+    this.severityOptions = [
+      {value: "1", inputDisplay: "low"},
+      {value: "2", inputDisplay: "medium"},
+      {value: "3", inputDisplay: "high"},
+    ];
+    this.tlpOptions = [
+      {value: "0", inputDisplay: "white"},
+      {value: "1", inputDisplay: "green"},
+      {value: "2", inputDisplay: "amber"},
+      {value: "3", inputDisplay: "red"},
+    ];
+    
+    // Table columns definition
+    this.columns = [
+      {
+        field: "id",
+        name: "Observable",
+      },
+      {
+        field: "descr",
+        name: "Description",
+        description: "Description of the observable in the context of the case",
+        render: (value, item) => (<EuiFieldText
+          value={item.descr}
+          onChange={(e) => this.props.onObsDescrChange(e, item.i)}
+          disabled={!item.selected}
+        />)
+      },
+      /*{
+        field: "tlp",
+        name: "TLP",
+        dataType: "number",
+        // TODO render and process changes
+      },*/
+      {
+        field: "ioc",
+        name: "Is IOC",
+        dataType: "boolean",
+        description: "Indicates if the observable is an IOC",
+        render: (value, item) => (<EuiCheckbox
+          id={"ioc-checkbox-"+item.id}
+          checked={item.ioc}
+          onChange={(e) => this.props.onObsIOCChange(e, item.i)}
+          disabled={!item.selected}
+        />)
+      },
+      /*{
+        field: "tags",
+        name: "Tags",
+        // TODO render and process changes
+      },*/
+    ]
+    
+    // Create a reference to observables table, so it's node can be accessed in componentDidMount
+    this.obsTableRef = React.createRef();
+  }
+  
+  // Main render function
+  render() {
+    const obs = this.props.observables;
+    // Create data for the observables table
+    let table_data = new Array(obs.length);
+    for (let i = 0; i < obs.length; i++) {
+      table_data[i] = {
+        id: obs[i],
+        descr: this.props.obsDescrs[i],
+        tlp: this.props.obsTLPs[i],
+        ioc: this.props.obsIOCs[i],
+        tags: this.props.obsTags[i],
+        // auxiliary fields, not shown in table:
+        i: i, // row index
+        selected: this.props.obsSelected.includes(i),
+      };
+    }
+    this.table_data = table_data; // needed in componentDidMount
+    
+    return (
+      <EuiOverlayMask>
+        <EuiModal onClose={this.props.close} maxWidth={false} initialFocus="[name=title]">
+          <EuiModalHeader>
+            <EuiModalHeaderTitle>Create a new case in The Hive</EuiModalHeaderTitle>
+          </EuiModalHeader>
+
+          <EuiModalBody>
+            <EuiForm style={{width: "800px"}}>
+              <EuiFlexGroup>
+                <EuiFlexItem grow={1}>
+                  <EuiFormRow label="Title" fullWidth>
+                    <EuiFieldText name="title" value={this.props.title} onChange={this.props.onTitleChange} required={true} fullWidth />
+                  </EuiFormRow>
+                </EuiFlexItem>
+                <EuiFlexItem grow={false}>
+                  <EuiFormRow label="Severity">
+                    <EuiSuperSelect
+                      options={this.severityOptions}
+                      valueOfSelected={this.props.severity}
+                      onChange={this.props.onSeverityChange}
+                    />
+                  </EuiFormRow>
+                </EuiFlexItem>
+                <EuiFlexItem grow={false}>
+                  <EuiFormRow label="TLP">
+                    <EuiSuperSelect
+                      prepend="TLP"
+                      options={this.tlpOptions}
+                      valueOfSelected={this.props.tlp}
+                      onChange={this.props.onTLPChange}
+                    />
+                  </EuiFormRow>
+                </EuiFlexItem>
+              </EuiFlexGroup>
+              <EuiFormRow label="Description" fullWidth>
+                <EuiTextArea
+                  defaultValue={this.props.description}
+                  onChange={this.props.onDescriptionChange}
+                  rows={4}
+                  fullWidth
+                />
+              </EuiFormRow>
+              
+              <EuiTitle size="s"><h3>Add observables from current query ...</h3></EuiTitle>
+              <EuiBasicTable
+                ref={this.obsTableRef}
+                columns={this.columns}
+                items={table_data}
+                itemId={(item) => item.id}
+                selection={ {onSelectionChange: this.props.onObsSelectionChange} }
+                noItemsMessage="No observables found"
+                rowProps={{
+                  // Hack to allow selection by clicking anywhere in the table row
+                  // (except input elements)
+                  onClick: (e) => {
+                    if (e.target.tagName != "INPUT") {
+                      // simulate click on the first checkbox in the row to (de)select the row
+                      e.currentTarget.querySelector("input").click();
+                      e.currentTarget.blur(); // without this the focus remains on the row after click (results in different color)
+                    }
+                  },
+                  tabIndex: "-1", // prevents focus on row by keyboard navigation
+                }}
+              />
+            </EuiForm>
+          </EuiModalBody>
+
+          <EuiModalFooter>
+            <EuiButtonEmpty onClick={this.props.close}>Cancel</EuiButtonEmpty>
+            <EuiButton onClick={this.props.submitCase} fill>Create Case</EuiButton>
+          </EuiModalFooter>
+        </EuiModal>
+      </EuiOverlayMask>
+    );
+  }
+  
+  componentDidMount() {
+    // There's no way to specify initially selected items in EuiBasicTable by 
+    // props, but we may need to select some (in case a user selects some obs.,
+    // closes the modal and opens it again).
+    // However, the selection is stored as a 'selection' field of table's state,
+    // so here we directly edit the state just after the table crated.
+    
+    // Prepare the 'selection' array - it should contain a list of selected row specifications
+    let selection = [];
+    for (let i = 0; i < this.props.obsSelected.length; i++) {
+      selection.push(this.table_data[this.props.obsSelected[i]]);
+    }
+    
+    // Get ref to EuiBasicTable element and update its state 
+    const table_node = this.obsTableRef.current;
+    table_node.setState({selection: selection});
+  }
+}
+
+
+// ********** Functions to send data to Kibana endpoints **********
+
+
+// Create a new Case in The Hive via its API
+// Return a Promise which resolves to object with ID of the new case ('id' attr) or error message ('error' attr)
+function createHiveCase(base_url, api_key, title, descr, severity, startDate, owner, flag, tlp, tags) {
+  // Prepare data
+  // TODO: check '/' at the end of base URL
+  var data = JSON.stringify({
+    "base_url": base_url,
+    "api_key": api_key,
+    "body": {
+      "title": title,
+      "description": descr,
+      "severity": severity, // number: 1=low, 2=medium, 3=high
+      "startDate": startDate,
+      "owner": owner, // user name the case will be assigned to
+      "flag": flag, // bool
+      "tlp": tlp, // number: 0=white, 1=green, 2=amber, 3=red
+      "tags": tags, // array of strings
+    }
+  });
+  console.log("TheHiveButton: Sending request to API endpoint 'new_case':", data);
+  var kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/new_case');
+
+  return new Promise(function (resolve, reject) {
+    // Create AJAX request
+    var xhr = new XMLHttpRequest();
+    
+    // Listener to process reply
+    xhr.onreadystatechange = function () {
+      if (this.readyState != 4) {
+        return; // response not ready yet
+      }
+      if (this.status == 200) {
+        const resp = JSON.parse(this.responseText);
+        console.log("TheHiveButton: Response from backend:", resp);
+        if ("error" in resp) {
+          resolve({"error": resp.error});
+        }
+        else if (resp.status_code != 201) {
+          resolve({"error": "Unexpected reply received from The Hive: [" + resp.status_code + "] " + resp.status_msg});
+        }
+        else {
+          resolve({"id": resp.body.id}); // return ID of the new case
+        }
+      }
+      else {
+        console.log("TheHiveButton: Error " + this.status + ": " + this.statusText);
+        resolve({"error": "Error " + this.status + ": " + this.statusText});
+      }
+    }
+
+    // Send the AJAX request
+    xhr.open("POST", kibana_endpoint_url);
+    xhr.setRequestHeader("Content-Type", "application/json");
+    xhr.setRequestHeader("kbn-xsrf", "thehive_plugin"); // this header must be set, although its content is probably irrelevant
+    xhr.send(data);
+  });
+}
+
+// Add observables to an existing Case in The Hive
+// (send the list of observables to our backend endpoint, it pushes them to The Hive)
+function addCaseObservables(base_url, api_key, caseid, observables) {
+  const kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/add_observables');
+  const data = JSON.stringify({
+    "base_url": base_url,
+    "api_key": api_key,
+    "caseid": caseid,
+    "observables": observables,
+  });
+  console.log("TheHiveButton: Sending request to API endpoint 'add_observables':", data);
+  
+  return new Promise(function (resolve, reject) {
+    // Create AJAX request
+    var xhr = new XMLHttpRequest();
+    
+    // Listener to process reply
+    xhr.onreadystatechange = function () {
+      if (this.readyState != 4) {
+        return; // response not ready yet
+      }
+      if (this.status == 200) {
+        const resp = JSON.parse(this.responseText);
+        console.log("TheHiveButton: Response from backend:", resp);
+        resolve(resp);
+      }
+      else {
+        console.log("TheHiveButton: Error " + this.status + ": " + this.statusText);
+        resolve({"error": "Error " + this.status + ": " + this.statusText});
+      }
+    }
+
+    // Send the AJAX request
+    xhr.open("POST", kibana_endpoint_url);
+    xhr.setRequestHeader("Content-Type", "application/json");
+    xhr.setRequestHeader("kbn-xsrf", "thehive_plugin"); // this header must be set, although its content is probably irrelevant
+    xhr.send(data);
+  });
+}
+
diff --git a/roles/build/templates/odfekibana/thehive_button/server/routes/newcase.js b/roles/build/templates/odfekibana/thehive_button/server/routes/newcase.js
new file mode 100644
index 0000000000000000000000000000000000000000..6f796ee82295b6e892a240ebf2eabe30ad207362
--- /dev/null
+++ b/roles/build/templates/odfekibana/thehive_button/server/routes/newcase.js
@@ -0,0 +1,154 @@
+const request = require('request');
+//const fs = require('fs');
+//const path = require('path');
+
+//const caFile = path.resolve(__dirname, '../../ca.cert.pem'); // TODO resolve where the CA file should be located / configured
+
+export default function (server) {
+  server.route({
+    path: '/api/thehive_button/new_case',
+    method: 'POST',
+    handler: newCaseHandler,
+  });
+  server.route({
+    path: '/api/thehive_button/add_observables',
+    method: 'POST',
+    handler: addObservablesHandler,
+  });
+}
+
+// Handler of ajax requests to create a new Case in The Hive
+function newCaseHandler(req, resp) {
+  // Parse the request to get connection parameters
+  // (everything is configured in forntend and sent as part of the request,
+  //  since I don't know how to configure the backend)
+  var base_url = req.payload['base_url'];
+  var api_key = req.payload['api_key'];
+  var req_body = req.payload['body'];
+
+  // check it's a valid URL with slash at the end
+  if (!base_url) {
+    return {'error': 'Base URL not set'};
+  }
+  if (!base_url.match(/https?:\/\/(([a-z\d.-]+)|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*\//i)) {
+  //if (!base_url.match(/https?:\/\/.*\//)) {
+    return {'error': 'Invalid base URL (it must begin with "http[s]" and end with "/")'};
+  }
+  // TODO add "/" to the end automatically
+  if (!api_key) {
+    return {'error': 'API key not set'};
+  }
+
+  return new Promise( function(resolve, reject) {
+    request({
+        method: 'POST',
+        url: base_url + 'api/case',
+        auth: {'bearer': api_key},
+        json: true,
+        body: req_body,
+        //ca: fs.readFileSync(caFile), // TODO resolve the issue with custom CA, where to get its cert?
+        rejectUnauthorized: false,
+      },
+      // handler of the reply from The Hive - just return as reply
+      function (error, response, body) {
+        // TODO: find out how to set response code, for now we always return sucess and encode original status code in the content
+        if (error) {
+          console.error("ERROR when trying to send request to The Hive:", error);
+          resolve({'error': error.message});
+        }
+        else {
+          if (response.statusCode < 200 || response.statusCode >= 300) {
+            console.error("ERROR Unexpected reply received from The Hive:", response.statusCode, response.statusMessage, "\n", body)
+          }
+          resolve({
+            'status_code': response.statusCode,
+            'status_msg': response.statusMessage,
+            'body': body
+          });
+        }
+      } // handler function
+    ); // request()
+  }); // Promise()
+}
+
+// Note:
+// There are two ways to create multiple Observables (artifacts) via The Hive API:
+// 1. post one request with an array of observables in "data" field
+//    - this allows to create all in one request, but doesn't allow to set 
+//      different parameters (IOC, TLP, etc.) to different observables
+// 2. post each observable in a separate request
+// The second way is used here.
+
+// Handler of ajax requests to add Observables to a Case in The Hive
+function addObservablesHandler(req, resp) {
+  // Parse the request to get connection parameters
+  // (everything is configured in forntend and sent as part of the request,
+  //  since I don't know how to configure the backend)
+  var base_url = req.payload['base_url'];
+  var api_key = req.payload['api_key'];
+
+  // check it's a valid URL with slash at the end
+  if (!base_url) {
+    return {'error': 'Base URL not set'};
+  }
+  if (!base_url.match(/https?:\/\/(([a-z\d.-]+)|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*\//i)) {
+  //if (!base_url.match(/https?:\/\/.*\//)) {
+    return {'error': 'Invalid base URL (it must begin with "http[s]" and end with "/")'};
+  }
+  // TODO add "/" to the end automatically
+  if (!api_key) {
+    return {'error': 'API key not set'};
+  }
+
+  const caseid = req.payload['caseid'];
+  const observables = req.payload['observables']; // array of obersvable specifications
+
+  return new Promise( async function(resolve, reject) {
+    // Run one request for each observable
+    // (A way to run multiple async tasks sequentially inspired by:
+    //  https://jrsinclair.com/articles/2019/how-to-run-async-js-in-parallel-or-sequential/ )
+    const starterPromise = Promise.resolve(null);
+    await observables.reduce(
+      (p, obs) => p.then(() => addObservable(base_url, api_key, caseid, obs)),
+      starterPromise
+    ).catch((err_msg) => {
+        console.error(err_msg); // log whole message
+        resolve({'error': err_msg.split("\n", 1)[0]}); // send the first line to frontend
+        return;
+      }
+    );
+    resolve({});
+  });
+}
+  
+function addObservable(base_url, api_key, caseid, obs) {
+  return new Promise( function(resolve, reject) {
+    console.log("Adding observable:", obs);
+    request({
+        method: 'POST',
+        url: base_url + 'api/case/' + caseid + "/artifact",
+        auth: {'bearer': api_key},
+        json: true,
+        body: obs,
+        //ca: fs.readFileSync(caFile), // TODO resolve the issue with custom CA, where to get its cert?
+        rejectUnauthorized: false,
+      },
+      // handler of the reply from The Hive - just return as reply
+      function (error, response, body) {
+        if (error) {
+          reject("ERROR when trying to send request to The Hive: " + error);
+        }
+        else if (response.statusCode < 200 || response.statusCode >= 300) {
+          reject("ERROR: Unexpected reply received from The Hive: " + response.statusCode + " " + response.statusMessage + "\n" + JSON.stringify(body));
+        }
+        else {
+          // success - continue with the next observable
+          resolve("OK");
+          resolve({})
+        }
+      } // handler function
+    ); // request()
+  }); //Promise()
+}
+
+
diff --git a/roles/odfekibana/files/env.js.j2 b/roles/odfekibana/files/env.js.j2
new file mode 100644
index 0000000000000000000000000000000000000000..a075a2781bbbed7d80132a22a7544c7235041018
--- /dev/null
+++ b/roles/odfekibana/files/env.js.j2
@@ -0,0 +1,4 @@
+// Default plugin configuration
+export const THEHIVE_URL = '{{THEHIVE_URL}}';
+export const THEHIVE_API_KEY = '{{THEHIVE_API_KEY}}';
+export const THEHIVE_OWNER = '{{THEHIVE_OWNER}}'; // default owner account of the created cases
diff --git a/roles/odfekibana/tasks/main.yml b/roles/odfekibana/tasks/main.yml
index 04426116397d59bd5d241ac133e1c46e3e767c96..77f2c37af6ab25b659fe5c0fc29c8e82820a040b 100644
--- a/roles/odfekibana/tasks/main.yml
+++ b/roles/odfekibana/tasks/main.yml
@@ -85,6 +85,17 @@
 #  tags:
 #    - start
 
+
+- name: Generate configuration for thehive_button plugin
+  template:
+    src: files/env.js.j2
+    dest: "/usr/share/kibana/plugins/thehive_button/public/env.js"
+    owner: kibana
+    group: kibana
+  tags:
+    - start
+
+
 - name: Start OpenDistro Kibana for Elasticsearch
   command: /usr/share/kibana/startkibana.sh
   #shell: exec /usr/share/kibana/bin/kibana -c config/kibana.yml &