diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
new file mode 100644
index 00000000000..6e6cb31e3ac
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -0,0 +1,121 @@
+
+
+
+  
+    
+      
+    
+
+    
+
+    
+      
+        
+      
+      
+        {{ s__('Badges|No badge image') }}
+      
+    
+
+    
+  
 
+
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
new file mode 100644
index 00000000000..ae942b2c1a7
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -0,0 +1,219 @@
+
+
+
+  
+
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
new file mode 100644
index 00000000000..ca7197e1e0f
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -0,0 +1,57 @@
+
+
+
+  
+    
+      {{ s__('Badges|Your badges') }}
+      {{ badges.length }}
+    
+    
+    
+      {{ s__('Badges|This group has no badges') }}
+      {{ s__('Badges|This project has no badges') }}
+    
+    
+      
+    
+  
 
+
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
new file mode 100644
index 00000000000..af062bdf8c6
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -0,0 +1,89 @@
+
+
+
+  
+    
+    
{{ badge.linkUrl }}
+    
+      {{ badgeKindText }}
+    
+    
+  
 
+
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
new file mode 100644
index 00000000000..83f78394238
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -0,0 +1,70 @@
+
+
+
+  
+
diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js
new file mode 100644
index 00000000000..8fbe3db5ef1
--- /dev/null
+++ b/app/assets/javascripts/badges/constants.js
@@ -0,0 +1,2 @@
+export const GROUP_BADGE = 'group';
+export const PROJECT_BADGE = 'project';
diff --git a/app/assets/javascripts/badges/empty_badge.js b/app/assets/javascripts/badges/empty_badge.js
new file mode 100644
index 00000000000..49a9b5e1be8
--- /dev/null
+++ b/app/assets/javascripts/badges/empty_badge.js
@@ -0,0 +1,7 @@
+export default () => ({
+  imageUrl: '',
+  isDeleting: false,
+  linkUrl: '',
+  renderedImageUrl: '',
+  renderedLinkUrl: '',
+});
diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js
new file mode 100644
index 00000000000..5542278b3e0
--- /dev/null
+++ b/app/assets/javascripts/badges/store/actions.js
@@ -0,0 +1,167 @@
+import axios from '~/lib/utils/axios_utils';
+import types from './mutation_types';
+
+export const transformBackendBadge = badge => ({
+  id: badge.id,
+  imageUrl: badge.image_url,
+  kind: badge.kind,
+  linkUrl: badge.link_url,
+  renderedImageUrl: badge.rendered_image_url,
+  renderedLinkUrl: badge.rendered_link_url,
+  isDeleting: false,
+});
+
+export default {
+  requestNewBadge({ commit }) {
+    commit(types.REQUEST_NEW_BADGE);
+  },
+  receiveNewBadge({ commit }, newBadge) {
+    commit(types.RECEIVE_NEW_BADGE, newBadge);
+  },
+  receiveNewBadgeError({ commit }) {
+    commit(types.RECEIVE_NEW_BADGE_ERROR);
+  },
+  addBadge({ dispatch, state }) {
+    const newBadge = state.badgeInAddForm;
+    const endpoint = state.apiEndpointUrl;
+    dispatch('requestNewBadge');
+    return axios
+      .post(endpoint, {
+        image_url: newBadge.imageUrl,
+        link_url: newBadge.linkUrl,
+      })
+      .catch(error => {
+        dispatch('receiveNewBadgeError');
+        throw error;
+      })
+      .then(res => {
+        dispatch('receiveNewBadge', transformBackendBadge(res.data));
+      });
+  },
+  requestDeleteBadge({ commit }, badgeId) {
+    commit(types.REQUEST_DELETE_BADGE, badgeId);
+  },
+  receiveDeleteBadge({ commit }, badgeId) {
+    commit(types.RECEIVE_DELETE_BADGE, badgeId);
+  },
+  receiveDeleteBadgeError({ commit }, badgeId) {
+    commit(types.RECEIVE_DELETE_BADGE_ERROR, badgeId);
+  },
+  deleteBadge({ dispatch, state }, badge) {
+    const badgeId = badge.id;
+    dispatch('requestDeleteBadge', badgeId);
+    const endpoint = `${state.apiEndpointUrl}/${badgeId}`;
+    return axios
+      .delete(endpoint)
+      .catch(error => {
+        dispatch('receiveDeleteBadgeError', badgeId);
+        throw error;
+      })
+      .then(() => {
+        dispatch('receiveDeleteBadge', badgeId);
+      });
+  },
+
+  editBadge({ commit }, badge) {
+    commit(types.START_EDITING, badge);
+  },
+
+  requestLoadBadges({ commit }, data) {
+    commit(types.REQUEST_LOAD_BADGES, data);
+  },
+  receiveLoadBadges({ commit }, badges) {
+    commit(types.RECEIVE_LOAD_BADGES, badges);
+  },
+  receiveLoadBadgesError({ commit }) {
+    commit(types.RECEIVE_LOAD_BADGES_ERROR);
+  },
+
+  loadBadges({ dispatch, state }, data) {
+    dispatch('requestLoadBadges', data);
+    const endpoint = state.apiEndpointUrl;
+    return axios
+      .get(endpoint)
+      .catch(error => {
+        dispatch('receiveLoadBadgesError');
+        throw error;
+      })
+      .then(res => {
+        dispatch('receiveLoadBadges', res.data.map(transformBackendBadge));
+      });
+  },
+
+  requestRenderedBadge({ commit }) {
+    commit(types.REQUEST_RENDERED_BADGE);
+  },
+  receiveRenderedBadge({ commit }, renderedBadge) {
+    commit(types.RECEIVE_RENDERED_BADGE, renderedBadge);
+  },
+  receiveRenderedBadgeError({ commit }) {
+    commit(types.RECEIVE_RENDERED_BADGE_ERROR);
+  },
+
+  renderBadge({ dispatch, state }) {
+    const badge = state.isEditing ? state.badgeInEditForm : state.badgeInAddForm;
+    const { linkUrl, imageUrl } = badge;
+    if (!linkUrl || linkUrl.trim() === '' || !imageUrl || imageUrl.trim() === '') {
+      return Promise.resolve(badge);
+    }
+
+    dispatch('requestRenderedBadge');
+
+    const parameters = [
+      `link_url=${encodeURIComponent(linkUrl)}`,
+      `image_url=${encodeURIComponent(imageUrl)}`,
+    ].join('&');
+    const renderEndpoint = `${state.apiEndpointUrl}/render?${parameters}`;
+    return axios
+      .get(renderEndpoint)
+      .catch(error => {
+        dispatch('receiveRenderedBadgeError');
+        throw error;
+      })
+      .then(res => {
+        dispatch('receiveRenderedBadge', transformBackendBadge(res.data));
+      });
+  },
+
+  requestUpdatedBadge({ commit }) {
+    commit(types.REQUEST_UPDATED_BADGE);
+  },
+  receiveUpdatedBadge({ commit }, updatedBadge) {
+    commit(types.RECEIVE_UPDATED_BADGE, updatedBadge);
+  },
+  receiveUpdatedBadgeError({ commit }) {
+    commit(types.RECEIVE_UPDATED_BADGE_ERROR);
+  },
+
+  saveBadge({ dispatch, state }) {
+    const badge = state.badgeInEditForm;
+    const endpoint = `${state.apiEndpointUrl}/${badge.id}`;
+    dispatch('requestUpdatedBadge');
+    return axios
+      .put(endpoint, {
+        image_url: badge.imageUrl,
+        link_url: badge.linkUrl,
+      })
+      .catch(error => {
+        dispatch('receiveUpdatedBadgeError');
+        throw error;
+      })
+      .then(res => {
+        dispatch('receiveUpdatedBadge', transformBackendBadge(res.data));
+      });
+  },
+
+  stopEditing({ commit }) {
+    commit(types.STOP_EDITING);
+  },
+
+  updateBadgeInForm({ commit }, badge) {
+    commit(types.UPDATE_BADGE_IN_FORM, badge);
+  },
+
+  updateBadgeInModal({ commit }, badge) {
+    commit(types.UPDATE_BADGE_IN_MODAL, badge);
+  },
+};
diff --git a/app/assets/javascripts/badges/store/index.js b/app/assets/javascripts/badges/store/index.js
new file mode 100644
index 00000000000..7a5df403a0e
--- /dev/null
+++ b/app/assets/javascripts/badges/store/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+  state: createState(),
+  actions,
+  mutations,
+});
diff --git a/app/assets/javascripts/badges/store/mutation_types.js b/app/assets/javascripts/badges/store/mutation_types.js
new file mode 100644
index 00000000000..d73f91b6005
--- /dev/null
+++ b/app/assets/javascripts/badges/store/mutation_types.js
@@ -0,0 +1,21 @@
+export default {
+  RECEIVE_DELETE_BADGE: 'RECEIVE_DELETE_BADGE',
+  RECEIVE_DELETE_BADGE_ERROR: 'RECEIVE_DELETE_BADGE_ERROR',
+  RECEIVE_LOAD_BADGES: 'RECEIVE_LOAD_BADGES',
+  RECEIVE_LOAD_BADGES_ERROR: 'RECEIVE_LOAD_BADGES_ERROR',
+  RECEIVE_NEW_BADGE: 'RECEIVE_NEW_BADGE',
+  RECEIVE_NEW_BADGE_ERROR: 'RECEIVE_NEW_BADGE_ERROR',
+  RECEIVE_RENDERED_BADGE: 'RECEIVE_RENDERED_BADGE',
+  RECEIVE_RENDERED_BADGE_ERROR: 'RECEIVE_RENDERED_BADGE_ERROR',
+  RECEIVE_UPDATED_BADGE: 'RECEIVE_UPDATED_BADGE',
+  RECEIVE_UPDATED_BADGE_ERROR: 'RECEIVE_UPDATED_BADGE_ERROR',
+  REQUEST_DELETE_BADGE: 'REQUEST_DELETE_BADGE',
+  REQUEST_LOAD_BADGES: 'REQUEST_LOAD_BADGES',
+  REQUEST_NEW_BADGE: 'REQUEST_NEW_BADGE',
+  REQUEST_RENDERED_BADGE: 'REQUEST_RENDERED_BADGE',
+  REQUEST_UPDATED_BADGE: 'REQUEST_UPDATED_BADGE',
+  START_EDITING: 'START_EDITING',
+  STOP_EDITING: 'STOP_EDITING',
+  UPDATE_BADGE_IN_FORM: 'UPDATE_BADGE_IN_FORM',
+  UPDATE_BADGE_IN_MODAL: 'UPDATE_BADGE_IN_MODAL',
+};
diff --git a/app/assets/javascripts/badges/store/mutations.js b/app/assets/javascripts/badges/store/mutations.js
new file mode 100644
index 00000000000..bd84e68c00f
--- /dev/null
+++ b/app/assets/javascripts/badges/store/mutations.js
@@ -0,0 +1,158 @@
+import types from './mutation_types';
+import { PROJECT_BADGE } from '../constants';
+
+const reorderBadges = badges =>
+  badges.sort((a, b) => {
+    if (a.kind !== b.kind) {
+      return a.kind === PROJECT_BADGE ? 1 : -1;
+    }
+
+    return a.id - b.id;
+  });
+
+export default {
+  [types.RECEIVE_NEW_BADGE](state, newBadge) {
+    Object.assign(state, {
+      badgeInAddForm: null,
+      badges: reorderBadges(state.badges.concat(newBadge)),
+      isSaving: false,
+      renderedBadge: null,
+    });
+  },
+  [types.RECEIVE_NEW_BADGE_ERROR](state) {
+    Object.assign(state, {
+      isSaving: false,
+    });
+  },
+  [types.REQUEST_NEW_BADGE](state) {
+    Object.assign(state, {
+      isSaving: true,
+    });
+  },
+
+  [types.RECEIVE_UPDATED_BADGE](state, updatedBadge) {
+    const badges = state.badges.map(badge => {
+      if (badge.id === updatedBadge.id) {
+        return updatedBadge;
+      }
+      return badge;
+    });
+    Object.assign(state, {
+      badgeInEditForm: null,
+      badges,
+      isEditing: false,
+      isSaving: false,
+      renderedBadge: null,
+    });
+  },
+  [types.RECEIVE_UPDATED_BADGE_ERROR](state) {
+    Object.assign(state, {
+      isSaving: false,
+    });
+  },
+  [types.REQUEST_UPDATED_BADGE](state) {
+    Object.assign(state, {
+      isSaving: true,
+    });
+  },
+
+  [types.RECEIVE_LOAD_BADGES](state, badges) {
+    Object.assign(state, {
+      badges: reorderBadges(badges),
+      isLoading: false,
+    });
+  },
+  [types.RECEIVE_LOAD_BADGES_ERROR](state) {
+    Object.assign(state, {
+      isLoading: false,
+    });
+  },
+  [types.REQUEST_LOAD_BADGES](state, data) {
+    Object.assign(state, {
+      kind: data.kind, // project or group
+      apiEndpointUrl: data.apiEndpointUrl,
+      docsUrl: data.docsUrl,
+      isLoading: true,
+    });
+  },
+
+  [types.RECEIVE_DELETE_BADGE](state, badgeId) {
+    const badges = state.badges.filter(badge => badge.id !== badgeId);
+    Object.assign(state, {
+      badges,
+    });
+  },
+  [types.RECEIVE_DELETE_BADGE_ERROR](state, badgeId) {
+    const badges = state.badges.map(badge => {
+      if (badge.id === badgeId) {
+        return {
+          ...badge,
+          isDeleting: false,
+        };
+      }
+
+      return badge;
+    });
+    Object.assign(state, {
+      badges,
+    });
+  },
+  [types.REQUEST_DELETE_BADGE](state, badgeId) {
+    const badges = state.badges.map(badge => {
+      if (badge.id === badgeId) {
+        return {
+          ...badge,
+          isDeleting: true,
+        };
+      }
+
+      return badge;
+    });
+    Object.assign(state, {
+      badges,
+    });
+  },
+
+  [types.RECEIVE_RENDERED_BADGE](state, renderedBadge) {
+    Object.assign(state, { isRendering: false, renderedBadge });
+  },
+  [types.RECEIVE_RENDERED_BADGE_ERROR](state) {
+    Object.assign(state, { isRendering: false });
+  },
+  [types.REQUEST_RENDERED_BADGE](state) {
+    Object.assign(state, { isRendering: true });
+  },
+
+  [types.START_EDITING](state, badge) {
+    Object.assign(state, {
+      badgeInEditForm: { ...badge },
+      isEditing: true,
+      renderedBadge: { ...badge },
+    });
+  },
+  [types.STOP_EDITING](state) {
+    Object.assign(state, {
+      badgeInEditForm: null,
+      isEditing: false,
+      renderedBadge: null,
+    });
+  },
+
+  [types.UPDATE_BADGE_IN_FORM](state, badge) {
+    if (state.isEditing) {
+      Object.assign(state, {
+        badgeInEditForm: badge,
+      });
+    } else {
+      Object.assign(state, {
+        badgeInAddForm: badge,
+      });
+    }
+  },
+
+  [types.UPDATE_BADGE_IN_MODAL](state, badge) {
+    Object.assign(state, {
+      badgeInModal: badge,
+    });
+  },
+};
diff --git a/app/assets/javascripts/badges/store/state.js b/app/assets/javascripts/badges/store/state.js
new file mode 100644
index 00000000000..43413aeb5bb
--- /dev/null
+++ b/app/assets/javascripts/badges/store/state.js
@@ -0,0 +1,13 @@
+export default () => ({
+  apiEndpointUrl: null,
+  badgeInAddForm: null,
+  badgeInEditForm: null,
+  badgeInModal: null,
+  badges: [],
+  docsUrl: null,
+  renderedBadge: null,
+  isEditing: false,
+  isLoading: false,
+  isRendering: false,
+  isSaving: false,
+});
diff --git a/app/assets/javascripts/pages/groups/settings/badges/index.js b/app/assets/javascripts/pages/groups/settings/badges/index.js
new file mode 100644
index 00000000000..74e96ee4a8f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/badges/index.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { GROUP_BADGE } from '~/badges/constants';
+import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+  mountBadgeSettings(GROUP_BADGE);
+});
diff --git a/app/assets/javascripts/pages/projects/settings/badges/index/index.js b/app/assets/javascripts/pages/projects/settings/badges/index/index.js
new file mode 100644
index 00000000000..30469550866
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/badges/index/index.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { PROJECT_BADGE } from '~/badges/constants';
+import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+  mountBadgeSettings(PROJECT_BADGE);
+});
diff --git a/app/assets/javascripts/pages/shared/mount_badge_settings.js b/app/assets/javascripts/pages/shared/mount_badge_settings.js
new file mode 100644
index 00000000000..1397c0834ff
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/mount_badge_settings.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import BadgeSettings from '~/badges/components/badge_settings.vue';
+import store from '~/badges/store';
+
+export default kind => {
+  const badgeSettingsElement = document.getElementById('badge-settings');
+
+  store.dispatch('loadBadges', {
+    kind,
+    apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl,
+    docsUrl: badgeSettingsElement.dataset.docsUrl,
+  });
+
+  return new Vue({
+    el: badgeSettingsElement,
+    store,
+    components: {
+      BadgeSettings,
+    },
+    render(createElement) {
+      return createElement(BadgeSettings);
+    },
+  });
+};
diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
index 7829d722560..34fccf6f0a4 100644
--- a/app/assets/stylesheets/framework/responsive_tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -39,7 +39,7 @@
 .table-section {
   white-space: nowrap;
 
-  $section-widths: 10 15 20 25 30 40 100;
+  $section-widths: 10 15 20 25 30 40 50 100;
   @each $width in $section-widths {
     &.section-#{$width} {
       flex: 0 0 #{$width + '%'};
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 9a770d77685..790e91e4431 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -1143,3 +1143,11 @@ pre.light-well {
     white-space: pre-wrap;
   }
 }
+
+.project-badge {
+  opacity: 0.9;
+
+  &:hover {
+    opacity: 1;
+  }
+}
diff --git a/app/controllers/groups/settings/badges_controller.rb b/app/controllers/groups/settings/badges_controller.rb
new file mode 100644
index 00000000000..edb334a3d88
--- /dev/null
+++ b/app/controllers/groups/settings/badges_controller.rb
@@ -0,0 +1,13 @@
+module Groups
+  module Settings
+    class BadgesController < Groups::ApplicationController
+      include GrapeRouteHelpers::NamedRouteMatcher
+
+      before_action :authorize_admin_group!
+
+      def index
+        @badge_api_endpoint = api_v4_groups_badges_path(id: @group.id)
+      end
+    end
+  end
+end
diff --git a/app/controllers/projects/settings/badges_controller.rb b/app/controllers/projects/settings/badges_controller.rb
new file mode 100644
index 00000000000..f7b70dd4b7b
--- /dev/null
+++ b/app/controllers/projects/settings/badges_controller.rb
@@ -0,0 +1,13 @@
+module Projects
+  module Settings
+    class BadgesController < Projects::ApplicationController
+      include GrapeRouteHelpers::NamedRouteMatcher
+
+      before_action :authorize_admin_project!
+
+      def index
+        @badge_api_endpoint = api_v4_projects_badges_path(id: @project.id)
+      end
+    end
+  end
+end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 16eceb3f48f..95fea2f18d1 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -1,6 +1,6 @@
 module GroupsHelper
   def group_nav_link_paths
-    %w[groups#projects groups#edit ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
+    %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
   end
 
   def group_sidebar_links
diff --git a/app/views/groups/settings/badges/index.html.haml b/app/views/groups/settings/badges/index.html.haml
new file mode 100644
index 00000000000..c7afb25d0f8
--- /dev/null
+++ b/app/views/groups/settings/badges/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Project Badges')
+- page_title _('Project Badges')
+
+= render 'shared/badges/badge_settings'
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 5ea19c9882d..517d9aa3d99 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -112,7 +112,7 @@
             %span.nav-item-name
               Settings
           %ul.sidebar-sub-level-items
-            = nav_link(path: %w[groups#projects groups#edit ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
+            = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
               = link_to edit_group_path(@group) do
                 %strong.fly-out-top-item-name
                   #{ _('Settings') }
@@ -122,6 +122,12 @@
                 %span
                   General
 
+            = nav_link(controller: :badges) do
+              = link_to group_settings_badges_path(@group), title: _('Project Badges') do
+                %span
+                  = _('Project Badges')
+
+
             = nav_link(path: 'groups#projects') do
               = link_to projects_group_path(@group), title: 'Projects' do
                 %span
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 5c90d13420f..93f674b9d3c 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -258,7 +258,7 @@
                   #{ _('Snippets') }
 
       - if project_nav_tab? :settings
-        = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do
+        = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show]) do
           = link_to edit_project_path(@project), class: 'shortcuts-tree' do
             .nav-icon-container
               = sprite_icon('settings')
@@ -268,7 +268,7 @@
           %ul.sidebar-sub-level-items
             - can_edit = can?(current_user, :admin_project, @project)
             - if can_edit
-              = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show], html_options: { class: "fly-out-top-item" } ) do
+              = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show], html_options: { class: "fly-out-top-item" } ) do
                 = link_to edit_project_path(@project) do
                   %strong.fly-out-top-item-name
                     #{ _('Settings') }
@@ -281,6 +281,11 @@
               = link_to project_project_members_path(@project), title: 'Members' do
                 %span
                   Members
+            - if can_edit
+              = nav_link(controller: :badges) do
+                = link_to project_settings_badges_path(@project), title: _('Badges') do
+                  %span
+                    = _('Badges')
             - if can_edit
               = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
                 = link_to project_settings_integrations_path(@project), title: 'Integrations' do
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index a2ecfddb163..043057e79ee 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -23,11 +23,14 @@
             - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
             = deleted_message % { project_name: fork_source_name(@project) }
 
-    .project-badges
+    .project-badges.prepend-top-default.append-bottom-default
       - @project.badges.each do |badge|
-        - badge_link_url = badge.rendered_link_url(@project)
-        %a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' }
-          %img{ src: badge.rendered_image_url(@project), alt: badge_link_url }
+        %a.append-right-8{ href: badge.rendered_link_url(@project),
+          target: '_blank',
+          rel: 'noopener noreferrer' }>
+          %img.project-badge{ src: badge.rendered_image_url(@project),
+            'aria-hidden': true,
+            alt: '' }>
 
     .project-repo-buttons
       .count-buttons
diff --git a/app/views/projects/settings/badges/index.html.haml b/app/views/projects/settings/badges/index.html.haml
new file mode 100644
index 00000000000..b68ed70de89
--- /dev/null
+++ b/app/views/projects/settings/badges/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Badges')
+- page_title _('Badges')
+
+= render 'shared/badges/badge_settings'
diff --git a/app/views/shared/badges/_badge_settings.html.haml b/app/views/shared/badges/_badge_settings.html.haml
new file mode 100644
index 00000000000..b7c250d3b1c
--- /dev/null
+++ b/app/views/shared/badges/_badge_settings.html.haml
@@ -0,0 +1,4 @@
+#badge-settings{ data: { api_endpoint_url: @badge_api_endpoint,
+  docs_url: help_page_path('user/project/badges')} }
+  .text-center.prepend-top-default
+    = icon('spinner spin 2x')
diff --git a/changelogs/unreleased/winh-41174-projects-groups-badges-ui.yml b/changelogs/unreleased/winh-41174-projects-groups-badges-ui.yml
new file mode 100644
index 00000000000..14114eca2b2
--- /dev/null
+++ b/changelogs/unreleased/winh-41174-projects-groups-badges-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Projects and groups badges settings UI
+merge_request: 17114
+author:
+type: added
diff --git a/config/karma.config.js b/config/karma.config.js
index 7ede745b591..c378e621953 100644
--- a/config/karma.config.js
+++ b/config/karma.config.js
@@ -39,7 +39,7 @@ module.exports = function(config) {
     frameworks: ['jasmine'],
     files: [
       { pattern: 'spec/javascripts/test_bundle.js', watched: false },
-      { pattern: 'spec/javascripts/fixtures/**/*@(.json|.html|.html.raw)', included: false },
+      { pattern: 'spec/javascripts/fixtures/**/*@(.json|.html|.html.raw|.png)', included: false },
     ],
     preprocessors: {
       'spec/javascripts/**/*.js': ['webpack', 'sourcemap'],
diff --git a/config/routes/group.rb b/config/routes/group.rb
index d89a714c7d6..170508e893d 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -24,6 +24,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
         constraints: { group_id: Gitlab::PathRegex.full_namespace_route_regex }) do
     namespace :settings do
       resource :ci_cd, only: [:show], controller: 'ci_cd'
+      resources :badges, only: [:index]
     end
 
     resource :variables, only: [:show, :update]
diff --git a/config/routes/project.rb b/config/routes/project.rb
index e760a9d7ed2..2a1bcb8cde2 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -435,6 +435,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
         resource :repository, only: [:show], controller: :repository do
           post :create_deploy_token, path: 'deploy_token/create'
         end
+        resources :badges, only: [:index]
       end
 
       # Since both wiki and repository routing contains wildcard characters
diff --git a/lib/api/badges.rb b/lib/api/badges.rb
index 334948b2995..8ceffe9c5ef 100644
--- a/lib/api/badges.rb
+++ b/lib/api/badges.rb
@@ -127,6 +127,7 @@ module API
           end
 
           destroy_conditionally!(badge)
+          body false
         end
       end
     end
diff --git a/spec/features/groups/settings/group_badges_spec.rb b/spec/features/groups/settings/group_badges_spec.rb
new file mode 100644
index 00000000000..92217294446
--- /dev/null
+++ b/spec/features/groups/settings/group_badges_spec.rb
@@ -0,0 +1,124 @@
+require 'spec_helper'
+
+feature 'Group Badges' do
+  include WaitForRequests
+
+  let(:user) { create(:user) }
+  let(:group) { create(:group) }
+  let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'}
+  let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'}
+  let!(:badge_1) { create(:group_badge, group: group) }
+  let!(:badge_2) { create(:group_badge, group: group) }
+
+  before do
+    group.add_owner(user)
+    sign_in(user)
+
+    visit(group_settings_badges_path(group))
+  end
+
+  it 'shows a list of badges', :js do
+    page.within '.badge-settings' do
+      wait_for_requests
+
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 2
+      expect(rows[0]).to have_content badge_1.link_url
+      expect(rows[1]).to have_content badge_2.link_url
+    end
+  end
+
+  context 'adding a badge', :js do
+    it 'user can preview a badge' do
+      page.within '.badge-settings form' do
+        fill_in 'badge-link-url', with: badge_link_url
+        fill_in 'badge-image-url', with: badge_image_url
+        within '#badge-preview' do
+          expect(find('a')[:href]).to eq badge_link_url
+          expect(find('a img')[:src]).to eq badge_image_url
+        end
+      end
+    end
+
+    it do
+      page.within '.badge-settings' do
+        fill_in 'badge-link-url', with: badge_link_url
+        fill_in 'badge-image-url', with: badge_image_url
+
+        click_button 'Add badge'
+        wait_for_requests
+
+        within '.panel-body' do
+          expect(find('a')[:href]).to eq badge_link_url
+          expect(find('a img')[:src]).to eq badge_image_url
+        end
+      end
+    end
+  end
+
+  context 'editing a badge', :js do
+    it 'form is shown when clicking edit button in list' do
+      page.within '.badge-settings' do
+        wait_for_requests
+        rows = all('.panel-body > div')
+        expect(rows.length).to eq 2
+        rows[1].find('[aria-label="Edit"]').click
+
+        within 'form' do
+          expect(find('#badge-link-url').value).to eq badge_2.link_url
+          expect(find('#badge-image-url').value).to eq badge_2.image_url
+        end
+      end
+    end
+
+    it 'updates a badge when submitting the edit form' do
+      page.within '.badge-settings' do
+        wait_for_requests
+        rows = all('.panel-body > div')
+        expect(rows.length).to eq 2
+        rows[1].find('[aria-label="Edit"]').click
+        within 'form' do
+          fill_in 'badge-link-url', with: badge_link_url
+          fill_in 'badge-image-url', with: badge_image_url
+
+          click_button 'Save changes'
+          wait_for_requests
+        end
+
+        rows = all('.panel-body > div')
+        expect(rows.length).to eq 2
+        expect(rows[1]).to have_content badge_link_url
+      end
+    end
+  end
+
+  context 'deleting a badge', :js do
+    def click_delete_button(badge_row)
+      badge_row.find('[aria-label="Delete"]').click
+    end
+
+    it 'shows a modal when deleting a badge' do
+      wait_for_requests
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 2
+
+      click_delete_button(rows[1])
+
+      expect(find('.modal .modal-title')).to have_content 'Delete badge?'
+    end
+
+    it 'deletes a badge when confirming the modal' do
+      wait_for_requests
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 2
+      click_delete_button(rows[1])
+
+      find('.modal .btn-danger').click
+      wait_for_requests
+
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 1
+      expect(rows[0]).to have_content badge_1.link_url
+    end
+  end
+end
diff --git a/spec/features/projects/settings/project_badges_spec.rb b/spec/features/projects/settings/project_badges_spec.rb
new file mode 100644
index 00000000000..cc3551a4c21
--- /dev/null
+++ b/spec/features/projects/settings/project_badges_spec.rb
@@ -0,0 +1,125 @@
+require 'spec_helper'
+
+feature 'Project Badges' do
+  include WaitForRequests
+
+  let(:user) { create(:user) }
+  let(:group) { create(:group) }
+  let(:project) { create(:project, namespace: group) }
+  let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'}
+  let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'}
+  let!(:project_badge) { create(:project_badge, project: project) }
+  let!(:group_badge) { create(:group_badge, group: group) }
+
+  before do
+    group.add_master(user)
+    sign_in(user)
+
+    visit(project_settings_badges_path(project))
+  end
+
+  it 'shows a list of badges', :js do
+    page.within '.badge-settings' do
+      wait_for_requests
+
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 2
+      expect(rows[0]).to have_content group_badge.link_url
+      expect(rows[1]).to have_content project_badge.link_url
+    end
+  end
+
+  context 'adding a badge', :js do
+    it 'user can preview a badge' do
+      page.within '.badge-settings form' do
+        fill_in 'badge-link-url', with: badge_link_url
+        fill_in 'badge-image-url', with: badge_image_url
+        within '#badge-preview' do
+          expect(find('a')[:href]).to eq badge_link_url
+          expect(find('a img')[:src]).to eq badge_image_url
+        end
+      end
+    end
+
+    it do
+      page.within '.badge-settings' do
+        fill_in 'badge-link-url', with: badge_link_url
+        fill_in 'badge-image-url', with: badge_image_url
+
+        click_button 'Add badge'
+        wait_for_requests
+
+        within '.panel-body' do
+          expect(find('a')[:href]).to eq badge_link_url
+          expect(find('a img')[:src]).to eq badge_image_url
+        end
+      end
+    end
+  end
+
+  context 'editing a badge', :js do
+    it 'form is shown when clicking edit button in list' do
+      page.within '.badge-settings' do
+        wait_for_requests
+        rows = all('.panel-body > div')
+        expect(rows.length).to eq 2
+        rows[1].find('[aria-label="Edit"]').click
+
+        within 'form' do
+          expect(find('#badge-link-url').value).to eq project_badge.link_url
+          expect(find('#badge-image-url').value).to eq project_badge.image_url
+        end
+      end
+    end
+
+    it 'updates a badge when submitting the edit form' do
+      page.within '.badge-settings' do
+        wait_for_requests
+        rows = all('.panel-body > div')
+        expect(rows.length).to eq 2
+        rows[1].find('[aria-label="Edit"]').click
+        within 'form' do
+          fill_in 'badge-link-url', with: badge_link_url
+          fill_in 'badge-image-url', with: badge_image_url
+
+          click_button 'Save changes'
+          wait_for_requests
+        end
+
+        rows = all('.panel-body > div')
+        expect(rows.length).to eq 2
+        expect(rows[1]).to have_content badge_link_url
+      end
+    end
+  end
+
+  context 'deleting a badge', :js do
+    def click_delete_button(badge_row)
+      badge_row.find('[aria-label="Delete"]').click
+    end
+
+    it 'shows a modal when deleting a badge' do
+      wait_for_requests
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 2
+
+      click_delete_button(rows[1])
+
+      expect(find('.modal .modal-title')).to have_content 'Delete badge?'
+    end
+
+    it 'deletes a badge when confirming the modal' do
+      wait_for_requests
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 2
+      click_delete_button(rows[1])
+
+      find('.modal .btn-danger').click
+      wait_for_requests
+
+      rows = all('.panel-body > div')
+      expect(rows.length).to eq 1
+      expect(rows[0]).to have_content group_badge.link_url
+    end
+  end
+end
diff --git a/spec/javascripts/badges/components/badge_form_spec.js b/spec/javascripts/badges/components/badge_form_spec.js
new file mode 100644
index 00000000000..dd21ec279cb
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_form_spec.js
@@ -0,0 +1,171 @@
+import Vue from 'vue';
+import store from '~/badges/store';
+import BadgeForm from '~/badges/components/badge_form.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeForm component', () => {
+  const Component = Vue.extend(BadgeForm);
+  let vm;
+
+  beforeEach(() => {
+    setFixtures(`
+      
+    `);
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  describe('methods', () => {
+    beforeEach(() => {
+      vm = mountComponentWithStore(Component, {
+        el: '#dummy-element',
+        store,
+        props: {
+          isEditing: false,
+        },
+      });
+    });
+
+    describe('onCancel', () => {
+      it('calls stopEditing', () => {
+        spyOn(vm, 'stopEditing');
+
+        vm.onCancel();
+
+        expect(vm.stopEditing).toHaveBeenCalled();
+      });
+    });
+
+    describe('onSubmit', () => {
+      describe('if isEditing is true', () => {
+        beforeEach(() => {
+          spyOn(vm, 'saveBadge').and.returnValue(Promise.resolve());
+          store.replaceState({
+            ...store.state,
+            isSaving: false,
+            badgeInEditForm: createDummyBadge(),
+          });
+          vm.isEditing = true;
+        });
+
+        it('returns immediately if imageUrl is empty', () => {
+          store.state.badgeInEditForm.imageUrl = '';
+
+          vm.onSubmit();
+
+          expect(vm.saveBadge).not.toHaveBeenCalled();
+        });
+
+        it('returns immediately if linkUrl is empty', () => {
+          store.state.badgeInEditForm.linkUrl = '';
+
+          vm.onSubmit();
+
+          expect(vm.saveBadge).not.toHaveBeenCalled();
+        });
+
+        it('returns immediately if isSaving is true', () => {
+          store.state.isSaving = true;
+
+          vm.onSubmit();
+
+          expect(vm.saveBadge).not.toHaveBeenCalled();
+        });
+
+        it('calls saveBadge', () => {
+          vm.onSubmit();
+
+          expect(vm.saveBadge).toHaveBeenCalled();
+        });
+      });
+
+      describe('if isEditing is false', () => {
+        beforeEach(() => {
+          spyOn(vm, 'addBadge').and.returnValue(Promise.resolve());
+          store.replaceState({
+            ...store.state,
+            isSaving: false,
+            badgeInAddForm: createDummyBadge(),
+          });
+          vm.isEditing = false;
+        });
+
+        it('returns immediately if imageUrl is empty', () => {
+          store.state.badgeInAddForm.imageUrl = '';
+
+          vm.onSubmit();
+
+          expect(vm.addBadge).not.toHaveBeenCalled();
+        });
+
+        it('returns immediately if linkUrl is empty', () => {
+          store.state.badgeInAddForm.linkUrl = '';
+
+          vm.onSubmit();
+
+          expect(vm.addBadge).not.toHaveBeenCalled();
+        });
+
+        it('returns immediately if isSaving is true', () => {
+          store.state.isSaving = true;
+
+          vm.onSubmit();
+
+          expect(vm.addBadge).not.toHaveBeenCalled();
+        });
+
+        it('calls addBadge', () => {
+          vm.onSubmit();
+
+          expect(vm.addBadge).toHaveBeenCalled();
+        });
+      });
+    });
+  });
+
+  describe('if isEditing is false', () => {
+    beforeEach(() => {
+      vm = mountComponentWithStore(Component, {
+        el: '#dummy-element',
+        store,
+        props: {
+          isEditing: false,
+        },
+      });
+    });
+
+    it('renders one button', () => {
+      const buttons = vm.$el.querySelectorAll('.row-content-block button');
+      expect(buttons.length).toBe(1);
+      const buttonAddElement = buttons[0];
+      expect(buttonAddElement).toBeVisible();
+      expect(buttonAddElement).toHaveText('Add badge');
+    });
+  });
+
+  describe('if isEditing is true', () => {
+    beforeEach(() => {
+      vm = mountComponentWithStore(Component, {
+        el: '#dummy-element',
+        store,
+        props: {
+          isEditing: true,
+        },
+      });
+    });
+
+    it('renders two buttons', () => {
+      const buttons = vm.$el.querySelectorAll('.row-content-block button');
+      expect(buttons.length).toBe(2);
+      const buttonSaveElement = buttons[0];
+      expect(buttonSaveElement).toBeVisible();
+      expect(buttonSaveElement).toHaveText('Save changes');
+      const buttonCancelElement = buttons[1];
+      expect(buttonCancelElement).toBeVisible();
+      expect(buttonCancelElement).toHaveText('Cancel');
+    });
+  });
+});
diff --git a/spec/javascripts/badges/components/badge_list_row_spec.js b/spec/javascripts/badges/components/badge_list_row_spec.js
new file mode 100644
index 00000000000..21bd00d82f0
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_list_row_spec.js
@@ -0,0 +1,97 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
+import store from '~/badges/store';
+import BadgeListRow from '~/badges/components/badge_list_row.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeListRow component', () => {
+  const Component = Vue.extend(BadgeListRow);
+  let badge;
+  let vm;
+
+  beforeEach(() => {
+    setFixtures(`
+      
+      
+    `);
+    store.replaceState({
+      ...store.state,
+      kind: PROJECT_BADGE,
+    });
+    badge = createDummyBadge();
+    vm = mountComponentWithStore(Component, {
+      el: '#dummy-element',
+      store,
+      props: { badge },
+    });
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  it('renders the badge', () => {
+    const badgeElement = vm.$el.querySelector('.project-badge');
+    expect(badgeElement).not.toBeNull();
+    expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
+  });
+
+  it('renders the badge link', () => {
+    expect(vm.$el).toContainText(badge.linkUrl);
+  });
+
+  it('renders the badge kind', () => {
+    expect(vm.$el).toContainText('Project Badge');
+  });
+
+  it('shows edit and delete buttons', () => {
+    const buttons = vm.$el.querySelectorAll('.table-button-footer button');
+    expect(buttons).toHaveLength(2);
+    const buttonEditElement = buttons[0];
+    expect(buttonEditElement).toBeVisible();
+    expect(buttonEditElement).toHaveSpriteIcon('pencil');
+    const buttonDeleteElement = buttons[1];
+    expect(buttonDeleteElement).toBeVisible();
+    expect(buttonDeleteElement).toHaveSpriteIcon('remove');
+  });
+
+  it('calls editBadge when clicking then edit button', () => {
+    spyOn(vm, 'editBadge');
+
+    const editButton = vm.$el.querySelector('.table-button-footer button:first-of-type');
+    editButton.click();
+
+    expect(vm.editBadge).toHaveBeenCalled();
+  });
+
+  it('calls updateBadgeInModal and shows modal when clicking then delete button', done => {
+    spyOn(vm, 'updateBadgeInModal');
+    $('#delete-badge-modal').on('shown.bs.modal', () => done());
+
+    const deleteButton = vm.$el.querySelector('.table-button-footer button:last-of-type');
+    deleteButton.click();
+
+    expect(vm.updateBadgeInModal).toHaveBeenCalled();
+  });
+
+  describe('for a group badge', () => {
+    beforeEach(done => {
+      badge.kind = GROUP_BADGE;
+
+      Vue.nextTick()
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('renders the badge kind', () => {
+      expect(vm.$el).toContainText('Group Badge');
+    });
+
+    it('hides edit and delete buttons', () => {
+      const buttons = vm.$el.querySelectorAll('.table-button-footer button');
+      expect(buttons).toHaveLength(0);
+    });
+  });
+});
diff --git a/spec/javascripts/badges/components/badge_list_spec.js b/spec/javascripts/badges/components/badge_list_spec.js
new file mode 100644
index 00000000000..9439c578973
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_list_spec.js
@@ -0,0 +1,88 @@
+import Vue from 'vue';
+import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
+import store from '~/badges/store';
+import BadgeList from '~/badges/components/badge_list.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeList component', () => {
+  const Component = Vue.extend(BadgeList);
+  const numberOfDummyBadges = 3;
+  let vm;
+
+  beforeEach(() => {
+    setFixtures('');
+    const badges = [];
+    for (let id = 0; id < numberOfDummyBadges; id += 1) {
+      badges.push({ id, ...createDummyBadge() });
+    }
+    store.replaceState({
+      ...store.state,
+      badges,
+      kind: PROJECT_BADGE,
+      isLoading: false,
+    });
+    vm = mountComponentWithStore(Component, {
+      el: '#dummy-element',
+      store,
+    });
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  it('renders a header with the badge count', () => {
+    const header = vm.$el.querySelector('.panel-heading');
+    expect(header).toHaveText(new RegExp(`Your badges\\s+${numberOfDummyBadges}`));
+  });
+
+  it('renders a row for each badge', () => {
+    const rows = vm.$el.querySelectorAll('.gl-responsive-table-row');
+    expect(rows).toHaveLength(numberOfDummyBadges);
+  });
+
+  it('renders a message if no badges exist', done => {
+    store.state.badges = [];
+
+    Vue.nextTick()
+      .then(() => {
+        expect(vm.$el).toContainText('This project has no badges');
+      })
+      .then(done)
+      .catch(done.fail);
+  });
+
+  it('shows a loading icon when loading', done => {
+    store.state.isLoading = true;
+
+    Vue.nextTick()
+      .then(() => {
+        const loadingIcon = vm.$el.querySelector('.fa-spinner');
+        expect(loadingIcon).toBeVisible();
+      })
+      .then(done)
+      .catch(done.fail);
+  });
+
+  describe('for group badges', () => {
+    beforeEach(done => {
+      store.state.kind = GROUP_BADGE;
+
+      Vue.nextTick()
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('renders a message if no badges exist', done => {
+      store.state.badges = [];
+
+      Vue.nextTick()
+        .then(() => {
+          expect(vm.$el).toContainText('This group has no badges');
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+  });
+});
diff --git a/spec/javascripts/badges/components/badge_settings_spec.js b/spec/javascripts/badges/components/badge_settings_spec.js
new file mode 100644
index 00000000000..3db02982ad4
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_settings_spec.js
@@ -0,0 +1,109 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import store from '~/badges/store';
+import BadgeSettings from '~/badges/components/badge_settings.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeSettings component', () => {
+  const Component = Vue.extend(BadgeSettings);
+  let vm;
+
+  beforeEach(() => {
+    setFixtures(`
+      
+      
+    `);
+    vm = mountComponentWithStore(Component, {
+      el: '#dummy-element',
+      store,
+    });
+  });
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  it('displays modal if button is clicked', done => {
+    const badge = createDummyBadge();
+    store.state.badgeInModal = badge;
+    const modal = vm.$el.querySelector('#delete-badge-modal');
+    const button = document.getElementById('dummy-modal-button');
+
+    $(modal).on('shown.bs.modal', () => {
+      expect(modal).toContainText('Delete badge?');
+      const badgeElement = modal.querySelector('img.project-badge');
+      expect(badgeElement).not.toBe(null);
+      expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
+
+      done();
+    });
+
+    Vue.nextTick()
+      .then(() => {
+        button.click();
+      })
+      .catch(done.fail);
+  });
+
+  it('displays a form to add a badge', () => {
+    const form = vm.$el.querySelector('form:nth-of-type(2)');
+    expect(form).not.toBe(null);
+    const button = form.querySelector('.btn-success');
+    expect(button).not.toBe(null);
+    expect(button).toHaveText(/Add badge/);
+  });
+
+  it('displays badge list', () => {
+    const badgeListElement = vm.$el.querySelector('.panel');
+    expect(badgeListElement).not.toBe(null);
+    expect(badgeListElement).toBeVisible();
+    expect(badgeListElement).toContainText('Your badges');
+  });
+
+  describe('when editing', () => {
+    beforeEach(done => {
+      store.state.isEditing = true;
+
+      Vue.nextTick()
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('displays a form to edit a badge', () => {
+      const form = vm.$el.querySelector('form:nth-of-type(1)');
+      expect(form).not.toBe(null);
+      const submitButton = form.querySelector('.btn-success');
+      expect(submitButton).not.toBe(null);
+      expect(submitButton).toHaveText(/Save changes/);
+      const cancelButton = form.querySelector('.btn-cancel');
+      expect(cancelButton).not.toBe(null);
+      expect(cancelButton).toHaveText(/Cancel/);
+    });
+
+    it('displays no badge list', () => {
+      const badgeListElement = vm.$el.querySelector('.panel');
+      expect(badgeListElement).toBeHidden();
+    });
+  });
+
+  describe('methods', () => {
+    describe('onSubmitModal', () => {
+      it('triggers ', () => {
+        spyOn(vm, 'deleteBadge').and.callFake(() => Promise.resolve());
+        const modal = vm.$el.querySelector('#delete-badge-modal');
+        const deleteButton = modal.querySelector('.btn-danger');
+
+        deleteButton.click();
+
+        const badge = store.state.badgeInModal;
+        expect(vm.deleteBadge).toHaveBeenCalledWith(badge);
+      });
+    });
+  });
+});
diff --git a/spec/javascripts/badges/components/badge_spec.js b/spec/javascripts/badges/components/badge_spec.js
new file mode 100644
index 00000000000..fd1ecc9cdd8
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_spec.js
@@ -0,0 +1,147 @@
+import Vue from 'vue';
+import Badge from '~/badges/components/badge.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
+
+describe('Badge component', () => {
+  const Component = Vue.extend(Badge);
+  const dummyProps = {
+    imageUrl: DUMMY_IMAGE_URL,
+    linkUrl: `${TEST_HOST}/badge/link/url`,
+  };
+  let vm;
+
+  const findElements = () => {
+    const buttons = vm.$el.querySelectorAll('button');
+    return {
+      badgeImage: vm.$el.querySelector('img.project-badge'),
+      loadingIcon: vm.$el.querySelector('.fa-spinner'),
+      reloadButton: buttons[buttons.length - 1],
+    };
+  };
+
+  const createComponent = (props, el = null) => {
+    vm = mountComponent(Component, props, el);
+    const { badgeImage } = findElements();
+    return new Promise(resolve => badgeImage.addEventListener('load', resolve)).then(() =>
+      Vue.nextTick(),
+    );
+  };
+
+  afterEach(() => {
+    vm.$destroy();
+  });
+
+  describe('watchers', () => {
+    describe('imageUrl', () => {
+      it('sets isLoading and resets numRetries and hasError', done => {
+        const props = { ...dummyProps };
+        createComponent(props)
+          .then(() => {
+            expect(vm.isLoading).toBe(false);
+            vm.hasError = true;
+            vm.numRetries = 42;
+
+            vm.imageUrl = `${props.imageUrl}#something/else`;
+
+            return Vue.nextTick();
+          })
+          .then(() => {
+            expect(vm.isLoading).toBe(true);
+            expect(vm.numRetries).toBe(0);
+            expect(vm.hasError).toBe(false);
+          })
+          .then(done)
+          .catch(done.fail);
+      });
+    });
+  });
+
+  describe('methods', () => {
+    beforeEach(done => {
+      createComponent({ ...dummyProps })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('onError resets isLoading and sets hasError', () => {
+      vm.hasError = false;
+      vm.isLoading = true;
+
+      vm.onError();
+
+      expect(vm.hasError).toBe(true);
+      expect(vm.isLoading).toBe(false);
+    });
+
+    it('onLoad sets isLoading', () => {
+      vm.isLoading = true;
+
+      vm.onLoad();
+
+      expect(vm.isLoading).toBe(false);
+    });
+
+    it('reloadImage resets isLoading and hasError and increases numRetries', () => {
+      vm.hasError = true;
+      vm.isLoading = false;
+      vm.numRetries = 0;
+
+      vm.reloadImage();
+
+      expect(vm.hasError).toBe(false);
+      expect(vm.isLoading).toBe(true);
+      expect(vm.numRetries).toBe(1);
+    });
+  });
+
+  describe('behavior', () => {
+    beforeEach(done => {
+      setFixtures('');
+      createComponent({ ...dummyProps }, '#dummy-element')
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('shows a badge image after loading', () => {
+      expect(vm.isLoading).toBe(false);
+      expect(vm.hasError).toBe(false);
+      const { badgeImage, loadingIcon, reloadButton } = findElements();
+      expect(badgeImage).toBeVisible();
+      expect(loadingIcon).toBeHidden();
+      expect(reloadButton).toBeHidden();
+      expect(vm.$el.innerText).toBe('');
+    });
+
+    it('shows a loading icon when loading', done => {
+      vm.isLoading = true;
+
+      Vue.nextTick()
+        .then(() => {
+          const { badgeImage, loadingIcon, reloadButton } = findElements();
+          expect(badgeImage).toBeHidden();
+          expect(loadingIcon).toBeVisible();
+          expect(reloadButton).toBeHidden();
+          expect(vm.$el.innerText).toBe('');
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('shows an error and reload button if loading failed', done => {
+      vm.hasError = true;
+
+      Vue.nextTick()
+        .then(() => {
+          const { badgeImage, loadingIcon, reloadButton } = findElements();
+          expect(badgeImage).toBeHidden();
+          expect(loadingIcon).toBeHidden();
+          expect(reloadButton).toBeVisible();
+          expect(reloadButton).toHaveSpriteIcon('retry');
+          expect(vm.$el.innerText.trim()).toBe('No badge image');
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+  });
+});
diff --git a/spec/javascripts/badges/dummy_badge.js b/spec/javascripts/badges/dummy_badge.js
new file mode 100644
index 00000000000..6aaff21c503
--- /dev/null
+++ b/spec/javascripts/badges/dummy_badge.js
@@ -0,0 +1,23 @@
+import { PROJECT_BADGE } from '~/badges/constants';
+import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
+
+export const createDummyBadge = () => {
+  const id = Math.floor(1000 * Math.random());
+  return {
+    id,
+    imageUrl: `${TEST_HOST}/badges/${id}/image/url`,
+    isDeleting: false,
+    linkUrl: `${TEST_HOST}/badges/${id}/link/url`,
+    kind: PROJECT_BADGE,
+    renderedImageUrl: `${DUMMY_IMAGE_URL}?id=${id}`,
+    renderedLinkUrl: `${TEST_HOST}/badges/${id}/rendered/link/url`,
+  };
+};
+
+export const createDummyBadgeResponse = () => ({
+  image_url: `${TEST_HOST}/badge/image/url`,
+  link_url: `${TEST_HOST}/badge/link/url`,
+  kind: PROJECT_BADGE,
+  rendered_image_url: DUMMY_IMAGE_URL,
+  rendered_link_url: `${TEST_HOST}/rendered/badge/link/url`,
+});
diff --git a/spec/javascripts/badges/store/actions_spec.js b/spec/javascripts/badges/store/actions_spec.js
new file mode 100644
index 00000000000..bb6263c6de4
--- /dev/null
+++ b/spec/javascripts/badges/store/actions_spec.js
@@ -0,0 +1,607 @@
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import actions, { transformBackendBadge } from '~/badges/store/actions';
+import mutationTypes from '~/badges/store/mutation_types';
+import createState from '~/badges/store/state';
+import { TEST_HOST } from 'spec/test_constants';
+import testAction from 'spec/helpers/vuex_action_helper';
+import { createDummyBadge, createDummyBadgeResponse } from '../dummy_badge';
+
+describe('Badges store actions', () => {
+  const dummyEndpointUrl = `${TEST_HOST}/badges/endpoint`;
+  const dummyBadges = [{ ...createDummyBadge(), id: 5 }, { ...createDummyBadge(), id: 6 }];
+
+  let axiosMock;
+  let badgeId;
+  let state;
+
+  beforeEach(() => {
+    axiosMock = new MockAdapter(axios);
+    state = {
+      ...createState(),
+      apiEndpointUrl: dummyEndpointUrl,
+      badges: dummyBadges,
+    };
+    badgeId = state.badges[0].id;
+  });
+
+  afterEach(() => {
+    axiosMock.restore();
+  });
+
+  describe('requestNewBadge', () => {
+    it('commits REQUEST_NEW_BADGE', done => {
+      testAction(
+        actions.requestNewBadge,
+        null,
+        state,
+        [{ type: mutationTypes.REQUEST_NEW_BADGE }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveNewBadge', () => {
+    it('commits RECEIVE_NEW_BADGE', done => {
+      const newBadge = createDummyBadge();
+      testAction(
+        actions.receiveNewBadge,
+        newBadge,
+        state,
+        [{ type: mutationTypes.RECEIVE_NEW_BADGE, payload: newBadge }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveNewBadgeError', () => {
+    it('commits RECEIVE_NEW_BADGE_ERROR', done => {
+      testAction(
+        actions.receiveNewBadgeError,
+        null,
+        state,
+        [{ type: mutationTypes.RECEIVE_NEW_BADGE_ERROR }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('addBadge', () => {
+    let badgeInAddForm;
+    let dispatch;
+    let endpointMock;
+
+    beforeEach(() => {
+      endpointMock = axiosMock.onPost(dummyEndpointUrl);
+      dispatch = jasmine.createSpy('dispatch');
+      badgeInAddForm = createDummyBadge();
+      state = {
+        ...state,
+        badgeInAddForm,
+      };
+    });
+
+    it('dispatches requestNewBadge and receiveNewBadge for successful response', done => {
+      const dummyResponse = createDummyBadgeResponse();
+
+      endpointMock.replyOnce(req => {
+        expect(req.data).toBe(
+          JSON.stringify({
+            image_url: badgeInAddForm.imageUrl,
+            link_url: badgeInAddForm.linkUrl,
+          }),
+        );
+        expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]);
+        dispatch.calls.reset();
+        return [200, dummyResponse];
+      });
+
+      const dummyBadge = transformBackendBadge(dummyResponse);
+      actions
+        .addBadge({ state, dispatch })
+        .then(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadge', dummyBadge]]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('dispatches requestNewBadge and receiveNewBadgeError for error response', done => {
+      endpointMock.replyOnce(req => {
+        expect(req.data).toBe(
+          JSON.stringify({
+            image_url: badgeInAddForm.imageUrl,
+            link_url: badgeInAddForm.linkUrl,
+          }),
+        );
+        expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]);
+        dispatch.calls.reset();
+        return [500, ''];
+      });
+
+      actions
+        .addBadge({ state, dispatch })
+        .then(() => done.fail('Expected Ajax call to fail!'))
+        .catch(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadgeError']]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+  });
+
+  describe('requestDeleteBadge', () => {
+    it('commits REQUEST_DELETE_BADGE', done => {
+      testAction(
+        actions.requestDeleteBadge,
+        badgeId,
+        state,
+        [{ type: mutationTypes.REQUEST_DELETE_BADGE, payload: badgeId }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveDeleteBadge', () => {
+    it('commits RECEIVE_DELETE_BADGE', done => {
+      testAction(
+        actions.receiveDeleteBadge,
+        badgeId,
+        state,
+        [{ type: mutationTypes.RECEIVE_DELETE_BADGE, payload: badgeId }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveDeleteBadgeError', () => {
+    it('commits RECEIVE_DELETE_BADGE_ERROR', done => {
+      testAction(
+        actions.receiveDeleteBadgeError,
+        badgeId,
+        state,
+        [{ type: mutationTypes.RECEIVE_DELETE_BADGE_ERROR, payload: badgeId }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('deleteBadge', () => {
+    let dispatch;
+    let endpointMock;
+
+    beforeEach(() => {
+      endpointMock = axiosMock.onDelete(`${dummyEndpointUrl}/${badgeId}`);
+      dispatch = jasmine.createSpy('dispatch');
+    });
+
+    it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', done => {
+      endpointMock.replyOnce(() => {
+        expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]);
+        dispatch.calls.reset();
+        return [200, ''];
+      });
+
+      actions
+        .deleteBadge({ state, dispatch }, { id: badgeId })
+        .then(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadge', badgeId]]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', done => {
+      endpointMock.replyOnce(() => {
+        expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]);
+        dispatch.calls.reset();
+        return [500, ''];
+      });
+
+      actions
+        .deleteBadge({ state, dispatch }, { id: badgeId })
+        .then(() => done.fail('Expected Ajax call to fail!'))
+        .catch(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadgeError', badgeId]]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+  });
+
+  describe('editBadge', () => {
+    it('commits START_EDITING', done => {
+      const dummyBadge = createDummyBadge();
+      testAction(
+        actions.editBadge,
+        dummyBadge,
+        state,
+        [{ type: mutationTypes.START_EDITING, payload: dummyBadge }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('requestLoadBadges', () => {
+    it('commits REQUEST_LOAD_BADGES', done => {
+      const dummyData = 'this is not real data';
+      testAction(
+        actions.requestLoadBadges,
+        dummyData,
+        state,
+        [{ type: mutationTypes.REQUEST_LOAD_BADGES, payload: dummyData }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveLoadBadges', () => {
+    it('commits RECEIVE_LOAD_BADGES', done => {
+      const badges = dummyBadges;
+      testAction(
+        actions.receiveLoadBadges,
+        badges,
+        state,
+        [{ type: mutationTypes.RECEIVE_LOAD_BADGES, payload: badges }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveLoadBadgesError', () => {
+    it('commits RECEIVE_LOAD_BADGES_ERROR', done => {
+      testAction(
+        actions.receiveLoadBadgesError,
+        null,
+        state,
+        [{ type: mutationTypes.RECEIVE_LOAD_BADGES_ERROR }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('loadBadges', () => {
+    let dispatch;
+    let endpointMock;
+
+    beforeEach(() => {
+      endpointMock = axiosMock.onGet(dummyEndpointUrl);
+      dispatch = jasmine.createSpy('dispatch');
+    });
+
+    it('dispatches requestLoadBadges and receiveLoadBadges for successful response', done => {
+      const dummyData = 'this is just some data';
+      const dummyReponse = [
+        createDummyBadgeResponse(),
+        createDummyBadgeResponse(),
+        createDummyBadgeResponse(),
+      ];
+      endpointMock.replyOnce(() => {
+        expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]);
+        dispatch.calls.reset();
+        return [200, dummyReponse];
+      });
+
+      actions
+        .loadBadges({ state, dispatch }, dummyData)
+        .then(() => {
+          const badges = dummyReponse.map(transformBackendBadge);
+          expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadges', badges]]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', done => {
+      const dummyData = 'this is just some data';
+      endpointMock.replyOnce(() => {
+        expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]);
+        dispatch.calls.reset();
+        return [500, ''];
+      });
+
+      actions
+        .loadBadges({ state, dispatch }, dummyData)
+        .then(() => done.fail('Expected Ajax call to fail!'))
+        .catch(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadgesError']]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+  });
+
+  describe('requestRenderedBadge', () => {
+    it('commits REQUEST_RENDERED_BADGE', done => {
+      testAction(
+        actions.requestRenderedBadge,
+        null,
+        state,
+        [{ type: mutationTypes.REQUEST_RENDERED_BADGE }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveRenderedBadge', () => {
+    it('commits RECEIVE_RENDERED_BADGE', done => {
+      const dummyBadge = createDummyBadge();
+      testAction(
+        actions.receiveRenderedBadge,
+        dummyBadge,
+        state,
+        [{ type: mutationTypes.RECEIVE_RENDERED_BADGE, payload: dummyBadge }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveRenderedBadgeError', () => {
+    it('commits RECEIVE_RENDERED_BADGE_ERROR', done => {
+      testAction(
+        actions.receiveRenderedBadgeError,
+        null,
+        state,
+        [{ type: mutationTypes.RECEIVE_RENDERED_BADGE_ERROR }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('renderBadge', () => {
+    let dispatch;
+    let endpointMock;
+    let badgeInForm;
+
+    beforeEach(() => {
+      badgeInForm = createDummyBadge();
+      state = {
+        ...state,
+        badgeInAddForm: badgeInForm,
+      };
+      const urlParameters = [
+        `link_url=${encodeURIComponent(badgeInForm.linkUrl)}`,
+        `image_url=${encodeURIComponent(badgeInForm.imageUrl)}`,
+      ].join('&');
+      endpointMock = axiosMock.onGet(`${dummyEndpointUrl}/render?${urlParameters}`);
+      dispatch = jasmine.createSpy('dispatch');
+    });
+
+    it('returns immediately if imageUrl is empty', done => {
+      spyOn(axios, 'get');
+      badgeInForm.imageUrl = '';
+
+      actions
+        .renderBadge({ state, dispatch })
+        .then(() => {
+          expect(axios.get).not.toHaveBeenCalled();
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('returns immediately if linkUrl is empty', done => {
+      spyOn(axios, 'get');
+      badgeInForm.linkUrl = '';
+
+      actions
+        .renderBadge({ state, dispatch })
+        .then(() => {
+          expect(axios.get).not.toHaveBeenCalled();
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('escapes user input', done => {
+      spyOn(axios, 'get').and.callFake(() => Promise.resolve({ data: createDummyBadgeResponse() }));
+      badgeInForm.imageUrl = '&make-sandwhich=true';
+      badgeInForm.linkUrl = '';
+
+      actions
+        .renderBadge({ state, dispatch })
+        .then(() => {
+          expect(axios.get.calls.count()).toBe(1);
+          const url = axios.get.calls.argsFor(0)[0];
+          expect(url).toMatch(`^${dummyEndpointUrl}/render?`);
+          expect(url).toMatch('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&');
+          expect(url).toMatch('&image_url=%26make-sandwhich%3Dtrue$');
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', done => {
+      const dummyReponse = createDummyBadgeResponse();
+      endpointMock.replyOnce(() => {
+        expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]);
+        dispatch.calls.reset();
+        return [200, dummyReponse];
+      });
+
+      actions
+        .renderBadge({ state, dispatch })
+        .then(() => {
+          const renderedBadge = transformBackendBadge(dummyReponse);
+          expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadge', renderedBadge]]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', done => {
+      endpointMock.replyOnce(() => {
+        expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]);
+        dispatch.calls.reset();
+        return [500, ''];
+      });
+
+      actions
+        .renderBadge({ state, dispatch })
+        .then(() => done.fail('Expected Ajax call to fail!'))
+        .catch(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadgeError']]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+  });
+
+  describe('requestUpdatedBadge', () => {
+    it('commits REQUEST_UPDATED_BADGE', done => {
+      testAction(
+        actions.requestUpdatedBadge,
+        null,
+        state,
+        [{ type: mutationTypes.REQUEST_UPDATED_BADGE }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveUpdatedBadge', () => {
+    it('commits RECEIVE_UPDATED_BADGE', done => {
+      const updatedBadge = createDummyBadge();
+      testAction(
+        actions.receiveUpdatedBadge,
+        updatedBadge,
+        state,
+        [{ type: mutationTypes.RECEIVE_UPDATED_BADGE, payload: updatedBadge }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('receiveUpdatedBadgeError', () => {
+    it('commits RECEIVE_UPDATED_BADGE_ERROR', done => {
+      testAction(
+        actions.receiveUpdatedBadgeError,
+        null,
+        state,
+        [{ type: mutationTypes.RECEIVE_UPDATED_BADGE_ERROR }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('saveBadge', () => {
+    let badgeInEditForm;
+    let dispatch;
+    let endpointMock;
+
+    beforeEach(() => {
+      badgeInEditForm = createDummyBadge();
+      state = {
+        ...state,
+        badgeInEditForm,
+      };
+      endpointMock = axiosMock.onPut(`${dummyEndpointUrl}/${badgeInEditForm.id}`);
+      dispatch = jasmine.createSpy('dispatch');
+    });
+
+    it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', done => {
+      const dummyResponse = createDummyBadgeResponse();
+
+      endpointMock.replyOnce(req => {
+        expect(req.data).toBe(
+          JSON.stringify({
+            image_url: badgeInEditForm.imageUrl,
+            link_url: badgeInEditForm.linkUrl,
+          }),
+        );
+        expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]);
+        dispatch.calls.reset();
+        return [200, dummyResponse];
+      });
+
+      const updatedBadge = transformBackendBadge(dummyResponse);
+      actions
+        .saveBadge({ state, dispatch })
+        .then(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadge', updatedBadge]]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+
+    it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', done => {
+      endpointMock.replyOnce(req => {
+        expect(req.data).toBe(
+          JSON.stringify({
+            image_url: badgeInEditForm.imageUrl,
+            link_url: badgeInEditForm.linkUrl,
+          }),
+        );
+        expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]);
+        dispatch.calls.reset();
+        return [500, ''];
+      });
+
+      actions
+        .saveBadge({ state, dispatch })
+        .then(() => done.fail('Expected Ajax call to fail!'))
+        .catch(() => {
+          expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadgeError']]);
+        })
+        .then(done)
+        .catch(done.fail);
+    });
+  });
+
+  describe('stopEditing', () => {
+    it('commits STOP_EDITING', done => {
+      testAction(
+        actions.stopEditing,
+        null,
+        state,
+        [{ type: mutationTypes.STOP_EDITING }],
+        [],
+        done,
+      );
+    });
+  });
+
+  describe('updateBadgeInForm', () => {
+    it('commits UPDATE_BADGE_IN_FORM', done => {
+      const dummyBadge = createDummyBadge();
+      testAction(
+        actions.updateBadgeInForm,
+        dummyBadge,
+        state,
+        [{ type: mutationTypes.UPDATE_BADGE_IN_FORM, payload: dummyBadge }],
+        [],
+        done,
+      );
+    });
+
+    describe('updateBadgeInModal', () => {
+      it('commits UPDATE_BADGE_IN_MODAL', done => {
+        const dummyBadge = createDummyBadge();
+        testAction(
+          actions.updateBadgeInModal,
+          dummyBadge,
+          state,
+          [{ type: mutationTypes.UPDATE_BADGE_IN_MODAL, payload: dummyBadge }],
+          [],
+          done,
+        );
+      });
+    });
+  });
+});
diff --git a/spec/javascripts/badges/store/mutations_spec.js b/spec/javascripts/badges/store/mutations_spec.js
new file mode 100644
index 00000000000..8d26f83339d
--- /dev/null
+++ b/spec/javascripts/badges/store/mutations_spec.js
@@ -0,0 +1,418 @@
+import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
+import store from '~/badges/store';
+import types from '~/badges/store/mutation_types';
+import createState from '~/badges/store/state';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('Badges store mutations', () => {
+  let dummyBadge;
+
+  beforeEach(() => {
+    dummyBadge = createDummyBadge();
+    store.replaceState(createState());
+  });
+
+  describe('RECEIVE_DELETE_BADGE', () => {
+    beforeEach(() => {
+      const badges = [
+        { ...dummyBadge, id: dummyBadge.id - 1 },
+        dummyBadge,
+        { ...dummyBadge, id: dummyBadge.id + 1 },
+      ];
+
+      store.replaceState({
+        ...store.state,
+        badges,
+      });
+    });
+
+    it('removes deleted badge', () => {
+      const badgeCount = store.state.badges.length;
+
+      store.commit(types.RECEIVE_DELETE_BADGE, dummyBadge.id);
+
+      expect(store.state.badges.length).toBe(badgeCount - 1);
+      expect(store.state.badges.indexOf(dummyBadge)).toBe(-1);
+    });
+  });
+
+  describe('RECEIVE_DELETE_BADGE_ERROR', () => {
+    beforeEach(() => {
+      const badges = [
+        { ...dummyBadge, id: dummyBadge.id - 1, isDeleting: false },
+        { ...dummyBadge, isDeleting: true },
+        { ...dummyBadge, id: dummyBadge.id + 1, isDeleting: true },
+      ];
+
+      store.replaceState({
+        ...store.state,
+        badges,
+      });
+    });
+
+    it('sets isDeleting to false', () => {
+      const badgeCount = store.state.badges.length;
+
+      store.commit(types.RECEIVE_DELETE_BADGE_ERROR, dummyBadge.id);
+
+      expect(store.state.badges.length).toBe(badgeCount);
+      expect(store.state.badges[0].isDeleting).toBe(false);
+      expect(store.state.badges[1].isDeleting).toBe(false);
+      expect(store.state.badges[2].isDeleting).toBe(true);
+    });
+  });
+
+  describe('RECEIVE_LOAD_BADGES', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isLoading: 'not false',
+      });
+    });
+
+    it('sets badges and isLoading to false', () => {
+      const badges = [createDummyBadge()];
+      store.commit(types.RECEIVE_LOAD_BADGES, badges);
+
+      expect(store.state.isLoading).toBe(false);
+      expect(store.state.badges).toBe(badges);
+    });
+  });
+
+  describe('RECEIVE_LOAD_BADGES_ERROR', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isLoading: 'not false',
+      });
+    });
+
+    it('sets isLoading to false', () => {
+      store.commit(types.RECEIVE_LOAD_BADGES_ERROR);
+
+      expect(store.state.isLoading).toBe(false);
+    });
+  });
+
+  describe('RECEIVE_NEW_BADGE', () => {
+    beforeEach(() => {
+      const badges = [
+        { ...dummyBadge, id: dummyBadge.id - 1, kind: GROUP_BADGE },
+        { ...dummyBadge, id: dummyBadge.id + 1, kind: GROUP_BADGE },
+        { ...dummyBadge, id: dummyBadge.id - 1, kind: PROJECT_BADGE },
+        { ...dummyBadge, id: dummyBadge.id + 1, kind: PROJECT_BADGE },
+      ];
+      store.replaceState({
+        ...store.state,
+        badgeInAddForm: createDummyBadge(),
+        badges,
+        isSaving: 'dummy value',
+        renderedBadge: createDummyBadge(),
+      });
+    });
+
+    it('resets the add form', () => {
+      store.commit(types.RECEIVE_NEW_BADGE, dummyBadge);
+
+      expect(store.state.badgeInAddForm).toBe(null);
+      expect(store.state.isSaving).toBe(false);
+      expect(store.state.renderedBadge).toBe(null);
+    });
+
+    it('inserts group badge at correct position', () => {
+      const badgeCount = store.state.badges.length;
+      dummyBadge = { ...dummyBadge, kind: GROUP_BADGE };
+
+      store.commit(types.RECEIVE_NEW_BADGE, dummyBadge);
+
+      expect(store.state.badges.length).toBe(badgeCount + 1);
+      expect(store.state.badges.indexOf(dummyBadge)).toBe(1);
+    });
+
+    it('inserts project badge at correct position', () => {
+      const badgeCount = store.state.badges.length;
+      dummyBadge = { ...dummyBadge, kind: PROJECT_BADGE };
+
+      store.commit(types.RECEIVE_NEW_BADGE, dummyBadge);
+
+      expect(store.state.badges.length).toBe(badgeCount + 1);
+      expect(store.state.badges.indexOf(dummyBadge)).toBe(3);
+    });
+  });
+
+  describe('RECEIVE_NEW_BADGE_ERROR', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isSaving: 'dummy value',
+      });
+    });
+
+    it('sets isSaving to false', () => {
+      store.commit(types.RECEIVE_NEW_BADGE_ERROR);
+
+      expect(store.state.isSaving).toBe(false);
+    });
+  });
+
+  describe('RECEIVE_RENDERED_BADGE', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isRendering: 'dummy value',
+        renderedBadge: 'dummy value',
+      });
+    });
+
+    it('sets renderedBadge', () => {
+      store.commit(types.RECEIVE_RENDERED_BADGE, dummyBadge);
+
+      expect(store.state.isRendering).toBe(false);
+      expect(store.state.renderedBadge).toBe(dummyBadge);
+    });
+  });
+
+  describe('RECEIVE_RENDERED_BADGE_ERROR', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isRendering: 'dummy value',
+      });
+    });
+
+    it('sets isRendering to false', () => {
+      store.commit(types.RECEIVE_RENDERED_BADGE_ERROR);
+
+      expect(store.state.isRendering).toBe(false);
+    });
+  });
+
+  describe('RECEIVE_UPDATED_BADGE', () => {
+    beforeEach(() => {
+      const badges = [
+        { ...dummyBadge, id: dummyBadge.id - 1 },
+        dummyBadge,
+        { ...dummyBadge, id: dummyBadge.id + 1 },
+      ];
+      store.replaceState({
+        ...store.state,
+        badgeInEditForm: createDummyBadge(),
+        badges,
+        isEditing: 'dummy value',
+        isSaving: 'dummy value',
+        renderedBadge: createDummyBadge(),
+      });
+    });
+
+    it('resets the edit form', () => {
+      store.commit(types.RECEIVE_UPDATED_BADGE, dummyBadge);
+
+      expect(store.state.badgeInAddForm).toBe(null);
+      expect(store.state.isSaving).toBe(false);
+      expect(store.state.renderedBadge).toBe(null);
+    });
+
+    it('replaces the updated badge', () => {
+      const badgeCount = store.state.badges.length;
+      const badgeIndex = store.state.badges.indexOf(dummyBadge);
+      const newBadge = { id: dummyBadge.id, dummy: 'value' };
+
+      store.commit(types.RECEIVE_UPDATED_BADGE, newBadge);
+
+      expect(store.state.badges.length).toBe(badgeCount);
+      expect(store.state.badges[badgeIndex]).toBe(newBadge);
+    });
+  });
+
+  describe('RECEIVE_UPDATED_BADGE_ERROR', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isSaving: 'dummy value',
+      });
+    });
+
+    it('sets isSaving to false', () => {
+      store.commit(types.RECEIVE_NEW_BADGE_ERROR);
+
+      expect(store.state.isSaving).toBe(false);
+    });
+  });
+
+  describe('REQUEST_DELETE_BADGE', () => {
+    beforeEach(() => {
+      const badges = [
+        { ...dummyBadge, id: dummyBadge.id - 1, isDeleting: false },
+        { ...dummyBadge, isDeleting: false },
+        { ...dummyBadge, id: dummyBadge.id + 1, isDeleting: true },
+      ];
+
+      store.replaceState({
+        ...store.state,
+        badges,
+      });
+    });
+
+    it('sets isDeleting to true', () => {
+      const badgeCount = store.state.badges.length;
+
+      store.commit(types.REQUEST_DELETE_BADGE, dummyBadge.id);
+
+      expect(store.state.badges.length).toBe(badgeCount);
+      expect(store.state.badges[0].isDeleting).toBe(false);
+      expect(store.state.badges[1].isDeleting).toBe(true);
+      expect(store.state.badges[2].isDeleting).toBe(true);
+    });
+  });
+
+  describe('REQUEST_LOAD_BADGES', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        apiEndpointUrl: 'some endpoint',
+        docsUrl: 'some url',
+        isLoading: 'dummy value',
+        kind: 'some kind',
+      });
+    });
+
+    it('sets isLoading to true and initializes the store', () => {
+      const dummyData = {
+        apiEndpointUrl: 'dummy endpoint',
+        docsUrl: 'dummy url',
+        kind: 'dummy kind',
+      };
+
+      store.commit(types.REQUEST_LOAD_BADGES, dummyData);
+
+      expect(store.state.isLoading).toBe(true);
+      expect(store.state.apiEndpointUrl).toBe(dummyData.apiEndpointUrl);
+      expect(store.state.docsUrl).toBe(dummyData.docsUrl);
+      expect(store.state.kind).toBe(dummyData.kind);
+    });
+  });
+
+  describe('REQUEST_NEW_BADGE', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isSaving: 'dummy value',
+      });
+    });
+
+    it('sets isSaving to true', () => {
+      store.commit(types.REQUEST_NEW_BADGE);
+
+      expect(store.state.isSaving).toBe(true);
+    });
+  });
+
+  describe('REQUEST_RENDERED_BADGE', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isRendering: 'dummy value',
+      });
+    });
+
+    it('sets isRendering to true', () => {
+      store.commit(types.REQUEST_RENDERED_BADGE);
+
+      expect(store.state.isRendering).toBe(true);
+    });
+  });
+
+  describe('REQUEST_UPDATED_BADGE', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        isSaving: 'dummy value',
+      });
+    });
+
+    it('sets isSaving to true', () => {
+      store.commit(types.REQUEST_NEW_BADGE);
+
+      expect(store.state.isSaving).toBe(true);
+    });
+  });
+
+  describe('START_EDITING', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        badgeInEditForm: 'dummy value',
+        isEditing: 'dummy value',
+        renderedBadge: 'dummy value',
+      });
+    });
+
+    it('initializes the edit form', () => {
+      store.commit(types.START_EDITING, dummyBadge);
+
+      expect(store.state.isEditing).toBe(true);
+      expect(store.state.badgeInEditForm).toEqual(dummyBadge);
+      expect(store.state.renderedBadge).toEqual(dummyBadge);
+    });
+  });
+
+  describe('STOP_EDITING', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        badgeInEditForm: 'dummy value',
+        isEditing: 'dummy value',
+        renderedBadge: 'dummy value',
+      });
+    });
+
+    it('resets the edit form', () => {
+      store.commit(types.STOP_EDITING);
+
+      expect(store.state.isEditing).toBe(false);
+      expect(store.state.badgeInEditForm).toBe(null);
+      expect(store.state.renderedBadge).toBe(null);
+    });
+  });
+
+  describe('UPDATE_BADGE_IN_FORM', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        badgeInAddForm: 'dummy value',
+        badgeInEditForm: 'dummy value',
+      });
+    });
+
+    it('sets badgeInEditForm if isEditing is true', () => {
+      store.state.isEditing = true;
+
+      store.commit(types.UPDATE_BADGE_IN_FORM, dummyBadge);
+
+      expect(store.state.badgeInEditForm).toBe(dummyBadge);
+    });
+
+    it('sets badgeInAddForm if isEditing is false', () => {
+      store.state.isEditing = false;
+
+      store.commit(types.UPDATE_BADGE_IN_FORM, dummyBadge);
+
+      expect(store.state.badgeInAddForm).toBe(dummyBadge);
+    });
+  });
+
+  describe('UPDATE_BADGE_IN_MODAL', () => {
+    beforeEach(() => {
+      store.replaceState({
+        ...store.state,
+        badgeInModal: 'dummy value',
+      });
+    });
+
+    it('sets badgeInModal', () => {
+      store.commit(types.UPDATE_BADGE_IN_MODAL, dummyBadge);
+
+      expect(store.state.badgeInModal).toBe(dummyBadge);
+    });
+  });
+});
diff --git a/spec/javascripts/fixtures/one_white_pixel.png b/spec/javascripts/fixtures/one_white_pixel.png
new file mode 100644
index 00000000000..073fcf40a18
Binary files /dev/null and b/spec/javascripts/fixtures/one_white_pixel.png differ
diff --git a/spec/javascripts/helpers/vue_mount_component_helper.js b/spec/javascripts/helpers/vue_mount_component_helper.js
index 34acdfbfba9..effacbcff4e 100644
--- a/spec/javascripts/helpers/vue_mount_component_helper.js
+++ b/spec/javascripts/helpers/vue_mount_component_helper.js
@@ -3,6 +3,12 @@ export const createComponentWithStore = (Component, store, propsData = {}) => ne
   propsData,
 });
 
+export const mountComponentWithStore = (Component, { el, props, store }) =>
+  new Component({
+    store,
+    propsData: props || { },
+  }).$mount(el);
+
 export default (Component, props = {}, el = null) => new Component({
   propsData: props,
 }).$mount(el);
diff --git a/spec/javascripts/matchers.js b/spec/javascripts/matchers.js
new file mode 100644
index 00000000000..7cc5e753c22
--- /dev/null
+++ b/spec/javascripts/matchers.js
@@ -0,0 +1,35 @@
+export default {
+  toHaveSpriteIcon: () => ({
+    compare(element, iconName) {
+      if (!iconName) {
+        throw new Error('toHaveSpriteIcon is missing iconName argument!');
+      }
+
+      if (!(element instanceof HTMLElement)) {
+        throw new Error(`${element} is not a DOM element!`);
+      }
+
+      const iconReferences = [].slice.apply(element.querySelectorAll('svg use'));
+      const matchingIcon = iconReferences.find(reference => reference.getAttribute('xlink:href').endsWith(`#${iconName}`));
+      const result = {
+        pass: !!matchingIcon,
+      };
+
+      if (result.pass) {
+        result.message = `${element.outerHTML} contains the sprite icon "${iconName}"!`;
+      } else {
+        result.message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`;
+
+        const existingIcons = iconReferences.map((reference) => {
+          const iconUrl = reference.getAttribute('xlink:href');
+          return `"${iconUrl.replace(/^.+#/, '')}"`;
+        });
+        if (existingIcons.length > 0) {
+          result.message += ` (only found ${existingIcons.join(',')})`;
+        }
+      }
+
+      return result;
+    },
+  }),
+};
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index 1bcfdfe72b6..d158786e484 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -7,6 +7,9 @@ import Vue from 'vue';
 import VueResource from 'vue-resource';
 
 import { getDefaultAdapter } from '~/lib/utils/axios_utils';
+import { FIXTURES_PATH, TEST_HOST } from './test_constants';
+
+import customMatchers from './matchers';
 
 const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent);
 Vue.config.devtools = !isHeadlessChrome;
@@ -27,15 +30,17 @@ Vue.config.errorHandler = function (err) {
 Vue.use(VueResource);
 
 // enable test fixtures
-jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures';
-jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures';
+jasmine.getFixtures().fixturesPath = FIXTURES_PATH;
+jasmine.getJSONFixtures().fixturesPath = FIXTURES_PATH;
+
+beforeAll(() => jasmine.addMatchers(customMatchers));
 
 // globalize common libraries
 window.$ = window.jQuery = $;
 
 // stub expected globals
 window.gl = window.gl || {};
-window.gl.TEST_HOST = 'http://test.host';
+window.gl.TEST_HOST = TEST_HOST;
 window.gon = window.gon || {};
 window.gon.test_env = true;
 
diff --git a/spec/javascripts/test_constants.js b/spec/javascripts/test_constants.js
new file mode 100644
index 00000000000..df59195e9f6
--- /dev/null
+++ b/spec/javascripts/test_constants.js
@@ -0,0 +1,4 @@
+export const FIXTURES_PATH = '/base/spec/javascripts/fixtures';
+export const TEST_HOST = 'http://test.host';
+
+export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`;