From 15bac1e4019fb3f05a643af3890a6e3f627e11d8 Mon Sep 17 00:00:00 2001
From: sfr <sol@solfisher.com>
Date: Sun, 6 Nov 2022 21:26:05 +0000
Subject: [PATCH] Add reports management (#186)

implements part of #178, other parts will come later

Co-authored-by: Sol Fisher Romanoff <sol@solfisher.com>
Reviewed-on: https://akkoma.dev/AkkomaGang/pleroma-fe/pulls/186
Co-authored-by: sfr <sol@solfisher.com>
Co-committed-by: sfr <sol@solfisher.com>
---
 src/App.js                                    |   2 +
 src/App.vue                                   |   1 +
 src/boot/after_store.js                       |   1 +
 src/components/desktop_nav/desktop_nav.js     |   9 +-
 src/components/desktop_nav/desktop_nav.vue    |  12 ++
 src/components/mod_modal/mod_modal.js         |  58 +++++
 src/components/mod_modal/mod_modal.scss       |  44 ++++
 src/components/mod_modal/mod_modal.vue        |  43 ++++
 src/components/mod_modal/mod_modal_content.js |  63 ++++++
 .../mod_modal/mod_modal_content.scss          |  21 ++
 .../mod_modal/mod_modal_content.vue           |  20 ++
 .../mod_modal/tabs/reports_tab/report_card.js | 124 +++++++++++
 .../tabs/reports_tab/report_card.vue          | 202 ++++++++++++++++++
 .../mod_modal/tabs/reports_tab/report_note.js |  37 ++++
 .../tabs/reports_tab/report_note.vue          |  43 ++++
 .../mod_modal/tabs/reports_tab/reports_tab.js |  26 +++
 .../tabs/reports_tab/reports_tab.scss         |  83 +++++++
 .../tabs/reports_tab/reports_tab.vue          |  20 ++
 src/components/side_drawer/side_drawer.js     |   9 +-
 src/components/side_drawer/side_drawer.vue    |  15 ++
 src/components/tab_switcher/tab_switcher.jsx  |   6 +-
 src/i18n/en.json                              |  26 +++
 src/modules/api.js                            |  14 ++
 src/modules/interface.js                      |  39 ++++
 src/modules/reports.js                        |  58 ++++-
 src/services/api/api.service.js               |  64 +++++-
 .../backend_interactor_service.js             |   5 +
 .../entity_normalizer.service.js              |  18 ++
 .../reports_fetcher.service.js                |  20 ++
 yarn.lock                                     |  31 +--
 30 files changed, 1082 insertions(+), 32 deletions(-)
 create mode 100644 src/components/mod_modal/mod_modal.js
 create mode 100644 src/components/mod_modal/mod_modal.scss
 create mode 100644 src/components/mod_modal/mod_modal.vue
 create mode 100644 src/components/mod_modal/mod_modal_content.js
 create mode 100644 src/components/mod_modal/mod_modal_content.scss
 create mode 100644 src/components/mod_modal/mod_modal_content.vue
 create mode 100644 src/components/mod_modal/tabs/reports_tab/report_card.js
 create mode 100644 src/components/mod_modal/tabs/reports_tab/report_card.vue
 create mode 100644 src/components/mod_modal/tabs/reports_tab/report_note.js
 create mode 100644 src/components/mod_modal/tabs/reports_tab/report_note.vue
 create mode 100644 src/components/mod_modal/tabs/reports_tab/reports_tab.js
 create mode 100644 src/components/mod_modal/tabs/reports_tab/reports_tab.scss
 create mode 100644 src/components/mod_modal/tabs/reports_tab/reports_tab.vue
 create mode 100644 src/services/reports_fetcher/reports_fetcher.service.js

diff --git a/src/App.js b/src/App.js
index 3690b944..d4b3b41a 100644
--- a/src/App.js
+++ b/src/App.js
@@ -5,6 +5,7 @@ import FeaturesPanel from './components/features_panel/features_panel.vue'
 import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
 import SettingsModal from './components/settings_modal/settings_modal.vue'
 import MediaModal from './components/media_modal/media_modal.vue'
+import ModModal from './components/mod_modal/mod_modal.vue'
 import SideDrawer from './components/side_drawer/side_drawer.vue'
 import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
 import MobileNav from './components/mobile_nav/mobile_nav.vue'
@@ -33,6 +34,7 @@ export default {
     MobileNav,
     DesktopNav,
     SettingsModal,
+    ModModal,
     UserReportingModal,
     PostStatusModal,
     EditStatusModal,
diff --git a/src/App.vue b/src/App.vue
index ca114c89..80ebb525 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -61,6 +61,7 @@
     <EditStatusModal v-if="editingAvailable" />
     <StatusHistoryModal v-if="editingAvailable" />
     <SettingsModal />
+    <ModModal />
     <GlobalNoticeList />
   </div>
 </template>
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 41c7b73e..986cd356 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -397,6 +397,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
   // Start fetching things that don't need to block the UI
   store.dispatch('fetchMutes')
   store.dispatch('startFetchingAnnouncements')
+  store.dispatch('startFetchingReports')
   getTOS({ store })
   getStickers({ store })
 
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index 9ba5abc4..78e93f0e 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -16,7 +16,8 @@ import {
   faUsers,
   faCommentMedical,
   faBookmark,
-  faInfoCircle
+  faInfoCircle,
+  faUserTie
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -34,7 +35,8 @@ library.add(
   faUsers,
   faCommentMedical,
   faBookmark,
-  faInfoCircle
+  faInfoCircle,
+  faUserTie
 )
 
 export default {
@@ -109,6 +111,9 @@ export default {
     },
     openSettingsModal () {
       this.$store.dispatch('openSettingsModal')
+    },
+    openModModal () {
+      this.$store.dispatch('openModModal')
     }
   }
 }
diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index 9dc02e68..0c592326 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -151,6 +151,18 @@
             :title="$t('nav.preferences')"
           />
         </button>
+        <button
+          v-if="currentUser && currentUser.role === 'admin' || currentUser.role === 'moderator'"
+          class="button-unstyled nav-icon"
+          @click.stop="openModModal"
+        >
+          <FAIcon
+            fixed-width
+            class="fa-scale-110 fa-old-padding"
+            icon="user-tie"
+            :title="$t('nav.moderation')"
+          />
+        </button>
         <a
           v-if="currentUser && currentUser.role === 'admin'"
           href="/pleroma/admin/#/login-pleroma"
diff --git a/src/components/mod_modal/mod_modal.js b/src/components/mod_modal/mod_modal.js
new file mode 100644
index 00000000..fb11ef87
--- /dev/null
+++ b/src/components/mod_modal/mod_modal.js
@@ -0,0 +1,58 @@
+import Modal from 'src/components/modal/modal.vue'
+import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
+import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
+import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faTimes,
+  faChevronDown
+} from '@fortawesome/free-solid-svg-icons'
+import {
+  faWindowMinimize
+} from '@fortawesome/free-regular-svg-icons'
+
+library.add(
+  faTimes,
+  faWindowMinimize,
+  faChevronDown
+)
+
+const ModModal = {
+  components: {
+    Modal,
+    ModModalContent: getResettableAsyncComponent(
+      () => import('./mod_modal_content.vue'),
+      {
+        loadingComponent: PanelLoading,
+        errorComponent: AsyncComponentError,
+        delay: 0
+      }
+    )
+  },
+  methods: {
+    closeModal () {
+      this.$store.dispatch('closeModModal')
+    },
+    peekModal () {
+      this.$store.dispatch('togglePeekModModal')
+    }
+  },
+  computed: {
+    moderator () {
+      return this.$store.state.users.currentUser &&
+        (this.$store.state.users.currentUser.role === 'admin' ||
+         this.$store.state.users.currentUser.role === 'moderator')
+    },
+    modalActivated () {
+      return this.$store.state.interface.modModalState !== 'hidden'
+    },
+    modalOpenedOnce () {
+      return this.$store.state.interface.modModalLoaded
+    },
+    modalPeeked () {
+      return this.$store.state.interface.modModalState === 'minimized'
+    }
+  }
+}
+
+export default ModModal
diff --git a/src/components/mod_modal/mod_modal.scss b/src/components/mod_modal/mod_modal.scss
new file mode 100644
index 00000000..4821df74
--- /dev/null
+++ b/src/components/mod_modal/mod_modal.scss
@@ -0,0 +1,44 @@
+@import 'src/_variables.scss';
+.mod-modal {
+  overflow: hidden;
+
+  &.peek {
+    .mod-modal-panel {
+      /* Explanation:
+       * Modal is positioned vertically centered.
+       * 100vh - 100% = Distance between modal's top+bottom boundaries and screen
+       * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
+       * + 100% - we move modal completely off-screen, it's top boundary touches
+       *   bottom of the screen
+       * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
+       */
+      transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
+
+      @media all and (max-width: 800px) {
+        /* For mobile, the modal takes 100% of the available screen.
+           This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible.
+        */
+        transform: translateY(calc(100% - 50px));
+      }
+    }
+  }
+
+  .mod-modal-panel {
+    overflow: hidden;
+    transition: transform;
+    transition-timing-function: ease-in-out;
+    transition-duration: 300ms;
+    width: 1000px;
+    max-width: 90vw;
+    height: 90vh;
+
+    @media all and (max-width: 800px) {
+      max-width: 100vw;
+      height: 100%;
+    }
+
+    .panel-body {
+      height: inherit;
+    }
+  }
+}
diff --git a/src/components/mod_modal/mod_modal.vue b/src/components/mod_modal/mod_modal.vue
new file mode 100644
index 00000000..64bbf021
--- /dev/null
+++ b/src/components/mod_modal/mod_modal.vue
@@ -0,0 +1,43 @@
+<template>
+  <Modal
+    v-if="moderator"
+    :is-open="modalActivated"
+    class="mod-modal"
+    :class="{ peek: modalPeeked }"
+    :no-background="modalPeeked"
+  >
+    <div class="mod-modal-panel panel">
+      <div class="panel-heading">
+        <span class="title">
+          {{ $t('moderation.moderation') }}
+        </span>
+        <button
+          class="btn button-default"
+          :title="$t('general.peek')"
+          @click="peekModal"
+        >
+          <FAIcon
+            :icon="['far', 'window-minimize']"
+            fixed-width
+          />
+        </button>
+        <button
+          class="btn button-default"
+          :title="$t('general.close')"
+          @click="closeModal"
+        >
+          <FAIcon
+            icon="times"
+            fixed-width
+          />
+        </button>
+      </div>
+      <div class="panel-body">
+        <ModModalContent v-if="modalOpenedOnce" />
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<script src="./mod_modal.js"></script>
+<style src="./mod_modal.scss" lang="scss"></style>
diff --git a/src/components/mod_modal/mod_modal_content.js b/src/components/mod_modal/mod_modal_content.js
new file mode 100644
index 00000000..e0ba6259
--- /dev/null
+++ b/src/components/mod_modal/mod_modal_content.js
@@ -0,0 +1,63 @@
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+
+import ReportsTab from './tabs/reports_tab/reports_tab.vue'
+// import StatusesTab from './tabs/statuses_tab.vue'
+// import UsersTab from './tabs/users_tab.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faFlag,
+  faMessage,
+  faUsers
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faFlag,
+  faMessage,
+  faUsers
+)
+
+const ModModalContent = {
+  components: {
+    TabSwitcher,
+
+    ReportsTab
+    // StatusesTab,
+    // UsersTab
+  },
+  computed: {
+    open () {
+      return this.$store.state.interface.modModalState !== 'hidden'
+    },
+    bodyLock () {
+      return this.$store.state.interface.modModalState === 'visible'
+    }
+  },
+  methods: {
+    onOpen () {
+      const targetTab = this.$store.state.interface.modModalTargetTab
+      // We're being told to open in specific tab
+      if (targetTab) {
+        const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => {
+          return elm.props && elm.props['data-tab-name'] === targetTab
+        })
+        if (tabIndex >= 0) {
+          this.$refs.tabSwitcher.setTab(tabIndex)
+        }
+      }
+      // Clear the state of target tab, so that next time moderation is opened
+      // it doesn't force it.
+      this.$store.dispatch('clearModModalTargetTab')
+    }
+  },
+  mounted () {
+    this.onOpen()
+  },
+  watch: {
+    open: function (value) {
+      if (value) this.onOpen()
+    }
+  }
+}
+
+export default ModModalContent
diff --git a/src/components/mod_modal/mod_modal_content.scss b/src/components/mod_modal/mod_modal_content.scss
new file mode 100644
index 00000000..b1aeba38
--- /dev/null
+++ b/src/components/mod_modal/mod_modal_content.scss
@@ -0,0 +1,21 @@
+@import 'src/_variables.scss';
+.mod_tab-switcher {
+  height: 100%;
+
+  .content {
+    margin: 1em 1em 1.4em;
+
+    > div {
+      margin-bottom: .5em;
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    textarea {
+      width: 100%;
+      max-width: 100%;
+      height: 100px;
+    }
+  }
+}
diff --git a/src/components/mod_modal/mod_modal_content.vue b/src/components/mod_modal/mod_modal_content.vue
new file mode 100644
index 00000000..6fa32be1
--- /dev/null
+++ b/src/components/mod_modal/mod_modal_content.vue
@@ -0,0 +1,20 @@
+<template>
+  <tab-switcher
+    ref="tabSwitcher"
+    class="mod_tab-switcher"
+    :side-tab-bar="true"
+    :scrollable-tabs="true"
+    :body-scroll-lock="bodyLock"
+  >
+    <div
+      :label="$t('moderation.reports.reports')"
+      icon="flag"
+      data-tab-name="reports"
+    >
+      <ReportsTab />
+    </div>
+  </tab-switcher>
+</template>
+
+<script src="./mod_modal_content.js"></script>
+<style src="./mod_modal_content.scss" lang="scss"></style>
diff --git a/src/components/mod_modal/tabs/reports_tab/report_card.js b/src/components/mod_modal/tabs/reports_tab/report_card.js
new file mode 100644
index 00000000..6e6bfdae
--- /dev/null
+++ b/src/components/mod_modal/tabs/reports_tab/report_card.js
@@ -0,0 +1,124 @@
+import Popover from 'src/components/popover/popover.vue'
+import Status from 'src/components/status/status.vue'
+import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
+import ReportNote from './report_note.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faChevronDown,
+  faChevronUp
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faChevronDown,
+  faChevronUp
+)
+
+const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
+const STRIP_MEDIA = 'mrf_tag:media-strip'
+const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
+const SANDBOX = 'mrf_tag:sandbox'
+
+const ReportCard = {
+  data () {
+    return {
+      hidden: true,
+      statusesHidden: true,
+      notesHidden: true,
+      note: null,
+      tags: {
+        FORCE_NSFW,
+        STRIP_MEDIA,
+        FORCE_UNLISTED,
+        SANDBOX
+      }
+    }
+  },
+  props: [
+    'account',
+    'actor',
+    'content',
+    'id',
+    'notes',
+    'state',
+    'statuses'
+  ],
+  components: {
+    ReportNote,
+    Popover,
+    Status,
+    UserAvatar
+  },
+  created () {
+    this.$store.dispatch('fetchUser', this.account.id)
+  },
+  computed: {
+    isOpen () {
+      return this.state === 'open'
+    },
+    tagPolicyEnabled () {
+      return this.$store.state.instance.federationPolicy.mrf_policies.includes('TagPolicy')
+    },
+    user () {
+      return this.$store.getters.findUser(this.account.id)
+    }
+  },
+  methods: {
+    toggleHidden () {
+      this.hidden = !this.hidden
+    },
+    decode (content) {
+      content = content.replaceAll('<br/>', '\n')
+      const textarea = document.createElement('textarea')
+      textarea.innerHTML = content
+      return textarea.value
+    },
+    updateReportState (state) {
+      this.$store.dispatch('updateReportStates', { reports: [{ id: this.id, state }] })
+    },
+    toggleNotes () {
+      this.notesHidden = !this.notesHidden
+    },
+    addNoteToReport () {
+      if (this.note.length > 0) {
+        this.$store.dispatch('addNoteToReport', { id: this.id, note: this.note })
+        this.note = null
+      }
+    },
+    toggleStatuses () {
+      this.statusesHidden = !this.statusesHidden
+    },
+    hasTag (tag) {
+      return this.user.tags.includes(tag)
+    },
+    toggleTag (tag) {
+      if (this.hasTag(tag)) {
+        this.$store.state.api.backendInteractor.untagUser({ user: this.user, tag }).then(response => {
+          if (!response.ok) { return }
+          this.$store.commit('untagUser', { user: this.user, tag })
+        })
+      } else {
+        this.$store.state.api.backendInteractor.tagUser({ user: this.user, tag }).then(response => {
+          if (!response.ok) { return }
+          this.$store.commit('tagUser', { user: this.user, tag })
+        })
+      }
+    },
+    toggleActivationStatus () {
+      this.$store.dispatch('toggleActivationStatus', { user: this.user })
+    },
+    deleteUser () {
+      this.$store.state.backendInteractor.deleteUser({ user: this.user })
+        .then(e => {
+          this.$store.dispatch('markStatusesAsDeleted', status => this.user.id === status.user.id)
+          const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
+          const isTargetUser = this.$route.params.name === this.user.name || this.$route.params.id === this.user.id
+          if (isProfile && isTargetUser) {
+            window.history.back()
+          }
+        })
+    }
+  }
+}
+
+export default ReportCard
diff --git a/src/components/mod_modal/tabs/reports_tab/report_card.vue b/src/components/mod_modal/tabs/reports_tab/report_card.vue
new file mode 100644
index 00000000..6cc034b1
--- /dev/null
+++ b/src/components/mod_modal/tabs/reports_tab/report_card.vue
@@ -0,0 +1,202 @@
+<template>
+  <div class="report-card panel">
+    <div
+      class="panel-heading"
+      @click="toggleHidden"
+    >
+      <h4>{{ $t('moderation.reports.report') + ' ' + this.account.screen_name }}</h4>
+      <button
+        v-if="isOpen"
+        class="button-default"
+        @click.stop="updateReportState('closed')"
+      >
+        {{ $t('moderation.reports.close') }}
+      </button>
+      <button
+        v-if="isOpen"
+        class="button-default"
+        @click.stop="updateReportState('resolved')"
+      >
+        {{ $t('moderation.reports.resolve') }}
+      </button>
+      <button
+        v-else
+        class="button-default"
+        @click.stop="updateReportState('open')"
+      >
+          {{ $t('moderation.reports.reopen') }}
+      </button>
+    </div>
+    <div
+      v-if="!hidden"
+      class="panel-body report-body"
+    >
+      <div class="report-content">
+        <div v-if="content">
+          {{ decode(content) }}
+        </div>
+        <i v-else class="faint">
+          {{ $t('moderation.reports.no_content') }}
+        </i>
+        <div class="report-author">
+          <UserAvatar
+            class="small-avatar"
+            :user="actor"
+          />
+          {{ this.actor.screen_name }}
+        </div>
+      </div>
+      <div
+        class="dropdown"
+        v-if="!hidden && this.statuses.length > 0"
+      >
+        <button
+          class="button button-unstyled dropdown-header"
+          @click="toggleStatuses"
+        >
+          {{ this.statuses.length + ' ' + $t('moderation.reports.statuses') }}
+          <FAIcon
+            class="timelines-chevron"
+            fixed-width
+            :icon="statusesHidden ? 'chevron-down' : 'chevron-up'"
+          />
+        </button>
+        <div v-if="!statusesHidden">
+          <Status
+            v-for="status in statuses"
+            :key="status.id"
+            :collapsable="false"
+            :expandable="false"
+            :compact="false"
+            :statusoid="status"
+            :no-heading="false"
+          />
+        </div>
+      </div>
+      <div
+        class="dropdown"
+        v-if="!hidden && this.notes.length > 0"
+      >
+        <button
+          class="button button-unstyled dropdown-header"
+          @click="toggleNotes"
+        >
+          {{ this.notes.length + ' ' + $t('moderation.reports.notes') }}
+          <FAIcon
+            class="timelines-chevron"
+            fixed-width
+            :icon="notesHidden ? 'chevron-down' : 'chevron-up'"
+          />
+        </button>
+        <div v-if="!notesHidden">
+          <ReportNote
+            v-for="note in notes"
+            :key="note.id"
+            :report_id="id"
+            v-bind="note"
+          />
+        </div>
+      </div>
+      <div class="report-add-note">
+        <textarea
+          rows="1"
+          cols="1"
+          v-model.trim="note"
+          :placeholder="$t('moderation.reports.note_placeholder')"
+        />
+        <button
+          class="btn button-default"
+          @click.stop="addNoteToReport"
+        >
+          {{ $t('moderation.reports.add_note') }}
+        </button>
+      </div>
+    </div>
+    <div
+      v-if="!hidden"
+      class="panel-footer"
+    >
+      <button
+        class="btn button-default"
+        @click.stop="toggleActivationStatus"
+      >
+        {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
+      </button>
+      <button
+        class="btn button-default"
+        @click.stop="deleteUser"
+      >
+        {{ $t('user_card.admin_menu.delete_account') }}
+      </button>
+      <Popover
+        trigger="click"
+        placement="top"
+        :offset="{ y: 5 }"
+        remove-padding
+      >
+        <template v-slot:trigger>
+          <button
+            class="btn button-default"
+            :disabled="!tagPolicyEnabled"
+            :title="tagPolicyEnabled ? '' : $t('moderation.reports.account.tag_policy_notice')"
+          >
+            <span>{{ $t("moderation.reports.tags") }}</span>
+            {{ ' ' }}
+            <FAIcon
+              icon="chevron-down"
+            />
+          </button>
+        </template>
+        <template v-slot:content="{close}">
+          <div
+            class="dropdown-menu"
+            :disabled="!tagPolicyEnabled"
+          >
+            <button
+              class="button-default dropdown-item dropdown-item-icon"
+              @click.prevent="toggleTag(tags.FORCE_NSFW)"
+            >
+              <span
+                class="menu-checkbox"
+                :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"
+              />
+              {{ $t('user_card.admin_menu.force_nsfw') }}
+            </button>
+            <button
+              class="button-default dropdown-item dropdown-item-icon"
+              @click.prevent="toggleTag(tags.STRIP_MEDIA)"
+            >
+              <span
+                class="menu-checkbox"
+                :class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"
+              />
+              {{ $t('user_card.admin_menu.strip_media') }}
+            </button>
+            <button
+              class="button-default dropdown-item dropdown-item-icon"
+              @click.prevent="toggleTag(tags.FORCE_UNLISTED)"
+            >
+              <span
+                class="menu-checkbox"
+                :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"
+              />
+              {{ $t('user_card.admin_menu.force_unlisted') }}
+            </button>
+            <button
+              class="button-default dropdown-item dropdown-item-icon"
+              @click.prevent="toggleTag(tags.SANDBOX)"
+            >
+              <span
+                class="menu-checkbox"
+                :class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"
+              />
+              {{ $t('user_card.admin_menu.sandbox') }}
+            </button>
+          </div>
+        </template>
+      </popover>
+    </div>
+  </div>
+</template>
+
+<script src="./report_card.js"></script>
diff --git a/src/components/mod_modal/tabs/reports_tab/report_note.js b/src/components/mod_modal/tabs/reports_tab/report_note.js
new file mode 100644
index 00000000..efcc8664
--- /dev/null
+++ b/src/components/mod_modal/tabs/reports_tab/report_note.js
@@ -0,0 +1,37 @@
+import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
+import Timeago from 'src/components/timeago/timeago.vue'
+import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
+
+const ReportNote = {
+  data () {
+    return {
+      showingDeleteDialog: false
+    }
+  },
+  props: [
+    'content',
+    'created_at',
+    'user',
+    'report_id',
+    'id'
+  ],
+  components: {
+    ConfirmModal,
+    Timeago,
+    UserAvatar
+  },
+  methods: {
+    deleteNoteFromReport () {
+      this.$store.dispatch('deleteNoteFromReport', { id: this.report_id, note: this.id })
+      this.showingDeleteDialog = false
+    },
+    showDeleteDialog () {
+      this.showingDeleteDialog = true
+    },
+    hideDeleteDialog () {
+      this.showingDeleteDialog = false
+    }
+  }
+}
+
+export default ReportNote
diff --git a/src/components/mod_modal/tabs/reports_tab/report_note.vue b/src/components/mod_modal/tabs/reports_tab/report_note.vue
new file mode 100644
index 00000000..49b3bd14
--- /dev/null
+++ b/src/components/mod_modal/tabs/reports_tab/report_note.vue
@@ -0,0 +1,43 @@
+<template>
+  <div class="report-note">
+    <div class="note-header">
+      <div class="note-author">
+        <UserAvatar
+          class="small-avatar"
+          :user="user"
+        />
+        {{ this.user.screen_name }}
+      </div>
+      <div class="header-right">
+        <Timeago
+          class="faint"
+          :time="created_at"
+          :auto-update="60"
+          :long-format="true"
+          :with-direction="true"
+        />
+        <button
+          class="btn button-default"
+          @click.stop="showDeleteDialog"
+        >
+          {{ $t('moderation.reports.delete_note') }}
+        </button>
+      </div>
+    </div>
+    <div class="note-content">
+      {{ content }}
+    </div>
+    <confirm-modal
+      v-if="showingDeleteDialog"
+      :title="$t('moderation.reports.delete_note_title')"
+      :confirm-text="$t('moderation.reports.delete_note_accept')"
+      :cancel-text="$t('moderation.reports.delete_note_cancel')"
+      @accepted="deleteNoteFromReport"
+      @cancelled="hideDeleteDialog"
+    >
+      {{ $t('moderation.reports.delete_note_confirm') }}
+    </confirm-modal>
+  </div>
+</template>
+
+<script src="./report_note.js"></script>
diff --git a/src/components/mod_modal/tabs/reports_tab/reports_tab.js b/src/components/mod_modal/tabs/reports_tab/reports_tab.js
new file mode 100644
index 00000000..1babc7ca
--- /dev/null
+++ b/src/components/mod_modal/tabs/reports_tab/reports_tab.js
@@ -0,0 +1,26 @@
+import { filter } from 'lodash'
+
+import ReportCard from './report_card.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
+
+const ReportsTab = {
+  data () {
+    return {
+      showClosed: false
+    }
+  },
+  components: {
+    Checkbox,
+    ReportCard
+  },
+  computed: {
+    reports () {
+      return this.$store.state.reports.reports
+    },
+    openReports () {
+      return filter(this.reports, { state: 'open' })
+    }
+  }
+}
+
+export default ReportsTab
diff --git a/src/components/mod_modal/tabs/reports_tab/reports_tab.scss b/src/components/mod_modal/tabs/reports_tab/reports_tab.scss
new file mode 100644
index 00000000..607f6726
--- /dev/null
+++ b/src/components/mod_modal/tabs/reports_tab/reports_tab.scss
@@ -0,0 +1,83 @@
+@import '../../../../_variables.scss';
+.report-card {
+  .report-body {
+    & > * {
+      padding: 1em;
+    }
+
+    & > :not(:last-child) {
+      border-bottom: 1px solid;
+      border-bottom-color: var(--border, #222);
+    }
+
+    .report-content {
+      white-space: pre-wrap;
+    }
+
+    .report-author {
+      padding-top: 0.5em;
+    }
+    .small-avatar {
+      height: 25px;
+      width: 25px;
+      padding-right: 0.4em;
+      vertical-align: middle;
+    }
+
+    .dropdown {
+      display: flex;
+      flex-direction: column;
+      padding: 0;
+
+      .dropdown-header {
+        padding: 1em;
+        color: var(--link, $fallback--link);
+
+        &:hover {
+          background-color: var(--selectedMenu, $fallback--lightBg);
+          color: var(--selectedMenuText, $fallback--link);
+        }
+      }
+    }
+
+    .report-note {
+      padding: 1em;
+
+      .note-header {
+        display: flex;
+        justify-content: space-between;
+        padding-bottom: 0.5em;
+      }
+
+      button {
+        margin-left: 0.5em;
+      }
+    }
+
+    .report-add-note {
+      textarea {
+        resize: none;
+      }
+
+      button {
+        min-height: 2em;
+        min-width: 10em;
+        padding: 0 2em;
+        margin-top: 0.5em;
+      }
+    }
+  }
+
+  .panel-footer {
+    display: flex;
+    & > * {
+      margin-right: 0.5em;
+    }
+  }
+}
+
+.reports-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
diff --git a/src/components/mod_modal/tabs/reports_tab/reports_tab.vue b/src/components/mod_modal/tabs/reports_tab/reports_tab.vue
new file mode 100644
index 00000000..aecda2e9
--- /dev/null
+++ b/src/components/mod_modal/tabs/reports_tab/reports_tab.vue
@@ -0,0 +1,20 @@
+<template>
+  <div :label="$t('moderation.reports.reports')">
+    <div class="content">
+      <div class="reports-header">
+        <h2>{{ $t('moderation.reports.reports') }}</h2>
+        <Checkbox v-model="showClosed">
+          {{ $t('moderation.reports.show_closed') }}
+        </Checkbox>
+      </div>
+      <ReportCard
+        v-for="report in (showClosed ? reports : openReports)"
+        :key="report.id"
+        v-bind="report"
+      />
+    </div>
+  </div>
+</template>
+
+<script src="./reports_tab.js"></script>
+<style src="./reports_tab.scss" lang="scss"></style>
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 1c8d5865..4e6e9edd 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -15,7 +15,8 @@ import {
   faTachometerAlt,
   faCog,
   faInfoCircle,
-  faList
+  faList,
+  faUserTie
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -30,7 +31,8 @@ library.add(
   faTachometerAlt,
   faCog,
   faInfoCircle,
-  faList
+  faList,
+  faUserTie
 )
 
 const SideDrawer = {
@@ -102,6 +104,9 @@ const SideDrawer = {
     },
     openSettingsModal () {
       this.$store.dispatch('openSettingsModal')
+    },
+    openModModal () {
+      this.$store.dispatch('openModModal')
     }
   }
 }
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 6d78fa85..86943e27 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -143,6 +143,21 @@
             /> {{ $t("nav.about") }}
           </router-link>
         </li>
+        <li
+          v-if="currentUser && currentUser.role === 'admin' || currentUser.role === 'moderator'"
+          @click="toggleDrawer"
+        >
+          <button
+            class="button-unstyled -link -fullwidth"
+            @click="openModModal"
+          >
+            <FAIcon
+              fixed-width
+              class="fa-scale-110 fa-old-padding"
+              icon="user-tie"
+            /> {{ $t("nav.moderation") }}
+          </button>
+        </li>
         <li
           v-if="currentUser && currentUser.role === 'admin'"
           @click="toggleDrawer"
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx
index c8d390bc..84fc14da 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -64,8 +64,12 @@ export default {
     settingsModalVisible () {
       return this.settingsModalState === 'visible'
     },
+    modModalVisible () {
+      return this.modModalState === 'visible'
+    },
     ...mapState({
-      settingsModalState: state => state.interface.settingsModalState
+      settingsModalState: state => state.interface.settingsModalState,
+      modModalState: state => state.interface.modModalState
     })
   },
   beforeUpdate () {
diff --git a/src/i18n/en.json b/src/i18n/en.json
index e920cf11..9cb5e3c7 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -266,6 +266,31 @@
         "next": "Next",
         "previous": "Previous"
     },
+    "moderation": {
+      "moderation": "Moderation",
+      "reports": {
+        "add_note": "Add note",
+        "close": "Close",
+        "delete_note": "Delete",
+        "delete_note_accept": "Yes, delete it",
+        "delete_note_cancel": "No, keep it",
+        "delete_note_confirm": "Are you sure you want to delete this note?",
+        "delete_note_title": "Confirm deletion",
+        "no_content": "No description given",
+        "note_placeholder": "Leave a note...",
+        "notes": "notes",
+        "reopen": "Reopen",
+        "report": "Report on",
+        "reports": "Reports",
+        "resolve": "Resolve",
+        "show_closed": "Show closed",
+        "statuses": "statuses",
+        "tag_policy_notice": "Enable the TagPolicy MRF to set post restrictions",
+        "tags": "Set post restrictions"
+      },
+      "statuses": "Statuses",
+      "users": "Users"
+    },
     "nav": {
         "about": "About",
         "administration": "Administration",
@@ -282,6 +307,7 @@
         "interactions": "Interactions",
         "lists": "Lists",
         "mentions": "Mentions",
+        "moderation": "Moderation",
         "preferences": "Preferences",
         "public_timeline_description": "Public posts from this instance",
         "public_tl": "Public timeline",
diff --git a/src/modules/api.js b/src/modules/api.js
index e2b3b37b..c54aa4fb 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -163,6 +163,7 @@ const api = {
                 dispatch('startFetchingTimeline', { timeline: 'friends' })
                 dispatch('startFetchingNotifications')
                 dispatch('startFetchingAnnouncements')
+                dispatch('startFetchingReports')
                 dispatch('pushGlobalNotice', {
                   level: 'error',
                   messageKey: 'timeline.socket_broke',
@@ -280,6 +281,19 @@ const api = {
       if (!fetcher) return
       store.commit('removeFetcher', { fetcherName: 'announcements', fetcher })
     },
+
+    // Reports
+    startFetchingReports (store) {
+      if (store.state.fetchers['reports']) return
+      const fetcher = store.state.backendInteractor.startFetchingReports({ store })
+      store.commit('addFetcher', { fetcherName: 'reports', fetcher })
+    },
+    stopFetchingReports (store) {
+      const fetcher = store.state.fetchers.reports
+      if (!fetcher) return
+      store.commit('removeFetcher', { fetcherName: 'reports', fetcher })
+    },
+
     getSupportedTranslationlanguages (store) {
       store.state.backendInteractor.getSupportedTranslationlanguages({ store })
         .then((data) => {
diff --git a/src/modules/interface.js b/src/modules/interface.js
index a86193ea..ae1a31c3 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -2,6 +2,9 @@ const defaultState = {
   settingsModalState: 'hidden',
   settingsModalLoaded: false,
   settingsModalTargetTab: null,
+  modModalState: 'hidden',
+  modModalLoaded: false,
+  modModalTargetTab: null,
   settings: {
     currentSaveStateNotice: null,
     noticeClearTimeout: null,
@@ -63,6 +66,30 @@ const interfaceMod = {
     setSettingsModalTargetTab (state, value) {
       state.settingsModalTargetTab = value
     },
+    closeModModal (state) {
+      state.modModalState = 'hidden'
+    },
+    togglePeekModModal (state) {
+      switch (state.modModalState) {
+        case 'minimized':
+          state.modModalState = 'visible'
+          return
+        case 'visible':
+          state.modModalState = 'minimized'
+          return
+        default:
+          throw new Error('Illegal minimization state of mod modal')
+      }
+    },
+    openModModal (state) {
+      state.modModalState = 'visible'
+      if (!state.modModalLoaded) {
+        state.modModalLoaded = true
+      }
+    },
+    setModModalTargetTab (state, value) {
+      state.modModalTargetTab = value
+    },
     pushGlobalNotice (state, notice) {
       state.globalNotices.push(notice)
     },
@@ -105,6 +132,18 @@ const interfaceMod = {
       commit('setSettingsModalTargetTab', value)
       commit('openSettingsModal')
     },
+    closeModModal ({ commit }) {
+      commit('closeModModal')
+    },
+    openModModal ({ commit }) {
+      commit('openModModal')
+    },
+    togglePeekModModal ({ commit }) {
+      commit('togglePeekModModal')
+    },
+    clearModModalTargetTab ({ commit }) {
+      commit('setModModalTargetTab', null)
+    },
     pushGlobalNotice (
       { commit, dispatch, state },
       {
diff --git a/src/modules/reports.js b/src/modules/reports.js
index fea83e5f..5130b989 100644
--- a/src/modules/reports.js
+++ b/src/modules/reports.js
@@ -1,11 +1,17 @@
-import filter from 'lodash/filter'
+import { filter, find, forEach, remove } from 'lodash'
+
+const getReport = (state, id) => find(state.reports, { id })
+const updateReport = (state, { report, param, value }) => {
+  getReport(state, report.id)[param] = value
+}
 
 const reports = {
   state: {
     userId: null,
     statuses: [],
     preTickedIds: [],
-    modalActivated: false
+    modalActivated: false,
+    reports: []
   },
   mutations: {
     openUserReportingModal (state, { userId, statuses, preTickedIds }) {
@@ -16,6 +22,38 @@ const reports = {
     },
     closeUserReportingModal (state) {
       state.modalActivated = false
+    },
+    setReport (state, { report }) {
+      let existing = getReport(state, report.id)
+      if (existing) {
+        existing = report
+      } else {
+        state.reports.push(report)
+      }
+    },
+    updateReportStates (state, { reports }) {
+      forEach(reports, (report) => {
+        updateReport(state, { report, param: 'state', value: report.state })
+      })
+    },
+    addNoteToReport (state, { id, note, user }) {
+      // akkoma doesn't return the note from this API endpoint, and there's no
+      // good way to get it. the note data is spoofed in the frontend until
+      // reload.
+      // definitely worth adding this to the backend at some point
+      const report = getReport(state, id)
+      const date = new Date()
+
+      report.notes.push({
+        content: note,
+        user,
+        created_at: date.toISOString(),
+        id: date.getTime()
+      })
+    },
+    deleteNoteFromReport (state, { id, note }) {
+      const report = getReport(state, id)
+      remove(report.notes, { id: note })
     }
   },
   actions: {
@@ -31,6 +69,22 @@ const reports = {
     },
     closeUserReportingModal ({ commit }) {
       commit('closeUserReportingModal')
+    },
+    updateReportStates ({ rootState, commit }, { reports }) {
+      commit('updateReportStates', { reports })
+      return rootState.api.backendInteractor.updateReportStates({ reports })
+    },
+    getReport ({ rootState, commit }, { id }) {
+      return rootState.api.backendInteractor.getReport({ id })
+        .then(report => commit('setReport', { report }))
+    },
+    addNoteToReport ({ rootState, commit }, { id, note }) {
+      commit('addNoteToReport', { id, note, user: rootState.users.currentUser })
+      return rootState.api.backendInteractor.addNoteToReport({ id, note })
+    },
+    deleteNoteFromReport ({ rootState, commit }, { id, note }) {
+      commit('deleteNoteFromReport', { id, note })
+      return rootState.api.backendInteractor.deleteNoteFromReport({ id, note })
     }
   }
 }
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 0cacf251..4e3e1ced 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,5 +1,5 @@
 import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseSource, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseSource, parseUser, parseNotification, parseReport, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
 import { RegistrationError, StatusCodeError } from '../errors/errors'
 
 /* eslint-env browser */
@@ -19,6 +19,9 @@ const ADMIN_USERS_URL = '/api/pleroma/admin/users'
 const SUGGESTIONS_URL = '/api/v1/suggestions'
 const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
 const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
+const ADMIN_REPORTS_URL = '/api/v1/pleroma/admin/reports'
+const ADMIN_REPORT_NOTES_URL = id => `/api/v1/pleroma/admin/reports/${id}/notes`
+const ADMIN_REPORT_NOTE_URL = (report, note) => `/api/v1/pleroma/admin/reports/${report}/notes/${note}`
 
 const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
 const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
@@ -342,7 +345,7 @@ const fetchUserRelationship = ({ id, credentials }) => {
       return new Promise((resolve, reject) => response.json()
         .then((json) => {
           if (!response.ok) {
-            return reject(new StatusCodeError(response.status, json, { url }, response))
+            return reject(new StatusCodeError(400, json, { url }, response))
           }
           return resolve(json)
         }))
@@ -635,6 +638,57 @@ const deleteUser = ({ credentials, user }) => {
   })
 }
 
+const getReports = ({ state, limit, page, pageSize, credentials }) => {
+  let url = ADMIN_REPORTS_URL
+  const args = [
+    state && `state=${state}`,
+    limit && `limit=${limit}`,
+    page && `page=${page}`,
+    pageSize && `page_size=${pageSize}`
+  ].filter(_ => _).join('&')
+
+  url = url + (args ? '?' + args : '')
+  return fetch(url, { headers: authHeaders(credentials) })
+    .then((data) => data.json())
+    .then((data) => data.reports.map(parseReport))
+}
+
+const updateReportStates = ({ credentials, reports }) => {
+  // reports syntax: [{ id: int, state: string }...]
+  const updates = {
+    reports: reports.map(report => {
+      return {
+        id: report.id.toString(),
+        state: report.state
+      }
+    })
+  }
+
+  return promisedRequest({
+    url: ADMIN_REPORTS_URL,
+    method: 'PATCH',
+    payload: updates,
+    credentials
+  })
+}
+
+const addNoteToReport = ({ id, note, credentials }) => {
+  return promisedRequest({
+    url: ADMIN_REPORT_NOTES_URL(id),
+    method: 'POST',
+    payload: { content: note },
+    credentials
+  })
+}
+
+const deleteNoteFromReport = ({ report, note, credentials }) => {
+  return promisedRequest({
+    url: ADMIN_REPORT_NOTE_URL(report, note),
+    method: 'DELETE',
+    credentials
+  })
+}
+
 const fetchTimeline = ({
   timeline,
   credentials,
@@ -1726,7 +1780,11 @@ const apiService = {
   getSettingsProfile,
   saveSettingsProfile,
   listSettingsProfiles,
-  deleteSettingsProfile
+  deleteSettingsProfile,
+  getReports,
+  updateReportStates,
+  addNoteToReport,
+  deleteNoteFromReport
 }
 
 export default apiService
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index 596151d8..4d6f80c2 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -5,6 +5,7 @@ import followRequestFetcher from '../../services/follow_request_fetcher/follow_r
 import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
 import announcementsFetcher from '../../services/announcements_fetcher/announcements_fetcher.service.js'
 import configFetcher from '../config_fetcher/config_fetcher.service.js'
+import reportsFetcher from '../reports_fetcher/reports_fetcher.service.js'
 
 const backendInteractorService = credentials => ({
   startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) {
@@ -39,6 +40,10 @@ const backendInteractorService = credentials => ({
     return announcementsFetcher.startFetching({ store, credentials })
   },
 
+  startFetchingReports ({ store, state, limit, page, pageSize }) {
+    return reportsFetcher.startFetching({ store, credentials, state, limit, page, pageSize })
+  },
+
   startUserSocket ({ store }) {
     const serv = store.rootState.instance.server.replace('http', 'ws')
     const url = serv + getMastodonSocketURI({ credentials, stream: 'user' })
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index b1aded33..a2fa741f 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -429,6 +429,24 @@ export const parseNotification = (data) => {
   return output
 }
 
+export const parseReport = (data) => {
+  const report = {}
+
+  report.account = parseUser(data.account)
+  report.actor = parseUser(data.actor)
+  report.statuses = data.statuses.map(parseStatus)
+  report.notes = data.notes.map(note => {
+    note.user = parseUser(note.user)
+    return note
+  })
+  report.state = data.state
+  report.content = data.content
+  report.created_at = data.created_at
+  report.id = data.id
+
+  return report
+}
+
 const isNsfw = (status) => {
   const nsfwRegex = /#nsfw/i
   return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
diff --git a/src/services/reports_fetcher/reports_fetcher.service.js b/src/services/reports_fetcher/reports_fetcher.service.js
new file mode 100644
index 00000000..f0bb9dcf
--- /dev/null
+++ b/src/services/reports_fetcher/reports_fetcher.service.js
@@ -0,0 +1,20 @@
+import apiService from '../api/api.service.js'
+import { promiseInterval } from '../promise_interval/promise_interval.js'
+import { forEach } from 'lodash'
+
+const fetchAndUpdate = ({ store, credentials, state, limit, page, pageSize }) => {
+  return apiService.getReports({ credentials, state, limit, page, pageSize })
+    .then(reports => forEach(reports, report => store.commit('setReport', { report })))
+}
+
+const startFetching = ({ store, credentials, state, limit, page, pageSize }) => {
+  const boundFetchAndUpdate = () => fetchAndUpdate({ store, credentials, state, limit, page, pageSize })
+  boundFetchAndUpdate()
+  return promiseInterval(boundFetchAndUpdate, 60000)
+}
+
+const reportsFetcher = {
+  startFetching
+}
+
+export default reportsFetcher
diff --git a/yarn.lock b/yarn.lock
index 86ae6b85..b8642176 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1092,12 +1092,12 @@
 
 "@fortawesome/fontawesome-common-types@^0.3.0":
   version "0.3.0"
-  resolved "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.3.0.tgz#949995a05c0d8801be7e0a594f775f1dbaa0d893"
   integrity sha512-CA3MAZBTxVsF6SkfkHXDerkhcQs0QPofy43eFdbWJJkZiq3SfiaH1msOkac59rQaqto5EqWnASboY1dBuKen5w==
 
 "@fortawesome/fontawesome-svg-core@1.3.0":
   version "1.3.0"
-  resolved "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.3.0.tgz#343fac91fa87daa630d26420bfedfba560f85885"
   integrity sha512-UIL6crBWhjTNQcONt96ExjUnKt1D68foe3xjEensLDclqQ6YagwCRYVQdrp/hW0ALRp/5Fv/VKw+MqTUWYYvPg==
   dependencies:
     "@fortawesome/fontawesome-common-types" "^0.3.0"
@@ -1141,9 +1141,9 @@
   integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
 
 "@intlify/bundle-utils@next":
-  version "3.1.2"
-  resolved "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-3.1.2.tgz"
-  integrity sha512-amgSo0NN5OKWYdcgFmfJqo2tcUcZ6C66Bxm5ALQnB0m3MUQtS9aJzKoIo+EU9XQiOVmlBFxRtNoZm+psHa5FNA==
+  version "3.2.1"
+  resolved "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-3.2.1.tgz"
+  integrity sha512-rf4cLBOnbqmpXVcCdcYHilZpMt1m82syh3WLBJlZvGxN2KkH9HeHVH4+bnibF/SDXCHNh6lM6wTpS/qw+PkcMg==
   dependencies:
     "@intlify/message-compiler" next
     "@intlify/shared" next
@@ -1168,7 +1168,7 @@
   dependencies:
     "@intlify/shared" "9.2.2"
 
-"@intlify/message-compiler@9.2.2":
+"@intlify/message-compiler@9.2.2", "@intlify/message-compiler@next":
   version "9.2.2"
   resolved "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.2.2.tgz"
   integrity sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA==
@@ -1176,24 +1176,11 @@
     "@intlify/shared" "9.2.2"
     source-map "0.6.1"
 
-"@intlify/message-compiler@next":
-  version "9.3.0-beta.3"
-  resolved "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.3.0-beta.3.tgz"
-  integrity sha512-j8OwToBQgs01RBMX4GCDNQfcnmw3AiDG3moKIONTrfXcf+1yt/rWznLTYH/DXbKcFMAFijFpCzMYjUmH1jVFYA==
-  dependencies:
-    "@intlify/shared" "9.3.0-beta.3"
-    source-map "0.6.1"
-
 "@intlify/shared@9.2.2", "@intlify/shared@next":
   version "9.2.2"
   resolved "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz"
   integrity sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==
 
-"@intlify/shared@9.3.0-beta.3":
-  version "9.3.0-beta.3"
-  resolved "https://registry.npmjs.org/@intlify/shared/-/shared-9.3.0-beta.3.tgz"
-  integrity sha512-Z/0TU4GhFKRxKh+0RbwJExik9zz57gXYgxSYaPn7YQdkQ/pabSioCY/SXnYxQHL6HzULF5tmqarFm6glbGqKhw==
-
 "@intlify/vue-devtools@9.2.2":
   version "9.2.2"
   resolved "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.2.2.tgz"
@@ -8095,9 +8082,9 @@ mute-stream@0.0.7:
   integrity sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==
 
 nan@^2.12.1:
-  version "2.16.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916"
-  integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==
+  version "2.17.0"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb"
+  integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==
 
 nanoid@^3.3.4:
   version "3.3.4"