diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 84b5f00728284c7906b7ddb751d1e42592c28aca..f3b537c83e213d84802ca1a8b183959c73127061 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -577,6 +577,9 @@ import initChangesDropdown from './init_changes_dropdown';
             case 'edit':
               shortcut_handler = new ShortcutsNavigation();
               new ProjectNew();
+              import(/* webpackChunkName: 'project_permissions' */ './projects/permissions')
+                .then(permissions => permissions.default())
+                .catch(() => {});
               break;
             case 'new':
               new ProjectNew();
diff --git a/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue b/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue
new file mode 100644
index 0000000000000000000000000000000000000000..80c5d39f7362789cd7b062bced4551f60da02a78
--- /dev/null
+++ b/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue
@@ -0,0 +1,104 @@
+<script>
+import projectFeatureToggle from './project_feature_toggle.vue';
+
+export default {
+  props: {
+    name: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    options: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+    value: {
+      type: Number,
+      required: false,
+      default: 0,
+    },
+    disabledInput: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+
+  components: {
+    projectFeatureToggle,
+  },
+
+  computed: {
+    featureEnabled() {
+      return this.value !== 0;
+    },
+
+    displayOptions() {
+      if (this.featureEnabled) {
+        return this.options;
+      }
+      return [
+        [0, 'Enable feature to choose access level'],
+      ];
+    },
+
+    displaySelectInput() {
+      return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2;
+    },
+  },
+
+  model: {
+    prop: 'value',
+    event: 'change',
+  },
+
+  methods: {
+    toggleFeature(featureEnabled) {
+      if (featureEnabled === false || this.options.length < 1) {
+        this.$emit('change', 0);
+      } else {
+        const [firstOptionValue] = this.options[this.options.length - 1];
+        this.$emit('change', firstOptionValue);
+      }
+    },
+
+    selectOption(e) {
+      this.$emit('change', Number(e.target.value));
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="project-feature-controls" :data-for="name">
+    <input
+      v-if="name"
+      type="hidden"
+      :name="name"
+      :value="value"
+    />
+    <project-feature-toggle
+      :value="featureEnabled"
+      @change="toggleFeature"
+      :disabledInput="disabledInput"
+    />
+    <div class="select-wrapper">
+      <select
+        class="form-control project-repo-select select-control"
+        @change="selectOption"
+        :disabled="displaySelectInput"
+      >
+        <option
+          v-for="[optionValue, optionName] in displayOptions"
+          :key="optionValue"
+          :value="optionValue"
+          :selected="optionValue === value"
+        >
+          {{optionName}}
+        </option>
+      </select>
+      <i aria-hidden="true" class="fa fa-chevron-down"></i>
+    </div>
+  </div>
+</template>
diff --git a/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue b/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2403c60186a465da401bb83644b3d90e1ef07e67
--- /dev/null
+++ b/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue
@@ -0,0 +1,51 @@
+<script>
+export default {
+  props: {
+    name: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    value: {
+      type: Boolean,
+      required: true,
+    },
+    disabledInput: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+
+  model: {
+    prop: 'value',
+    event: 'change',
+  },
+
+  methods: {
+    toggleFeature() {
+      if (!this.disabledInput) this.$emit('change', !this.value);
+    },
+  },
+};
+</script>
+
+<template>
+  <label class="toggle-wrapper">
+    <input
+      v-if="name"
+      type="hidden"
+      :name="name"
+      :value="value"
+    />
+    <button
+      type="button"
+      aria-label="Toggle"
+      class="project-feature-toggle"
+      data-enabled-text="Enabled"
+      data-disabled-text="Disabled"
+      :class="{ checked: value, disabled: disabledInput }"
+      @click="toggleFeature"
+    />
+  </label>
+</template>
diff --git a/app/assets/javascripts/projects/permissions/components/project_setting_row.vue b/app/assets/javascripts/projects/permissions/components/project_setting_row.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6140d74fea81e619526131a4e4c0241dbd1ff3b5
--- /dev/null
+++ b/app/assets/javascripts/projects/permissions/components/project_setting_row.vue
@@ -0,0 +1,36 @@
+<script>
+export default {
+  props: {
+    label: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    helpPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    helpText: {
+      type: String,
+      required: false,
+      default: null,
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="project-feature-row">
+    <label v-if="label" class="label-light">
+      {{label}}
+      <a v-if="helpPath" :href="helpPath" target="_blank">
+        <i aria-hidden="true" data-hidden="true" class="fa fa-question-circle"></i>
+      </a>
+    </label>
+    <span v-if="helpText" class="help-block">
+      {{helpText}}
+    </span>
+    <slot />
+  </div>
+</template>
diff --git a/app/assets/javascripts/projects/permissions/components/settings_panel.vue b/app/assets/javascripts/projects/permissions/components/settings_panel.vue
new file mode 100644
index 0000000000000000000000000000000000000000..326d910566690979ec90820e83efb93e628baecd
--- /dev/null
+++ b/app/assets/javascripts/projects/permissions/components/settings_panel.vue
@@ -0,0 +1,312 @@
+<script>
+import projectFeatureSetting from './project_feature_setting.vue';
+import projectFeatureToggle from './project_feature_toggle.vue';
+import projectSettingRow from './project_setting_row.vue';
+import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
+import { toggleHiddenClassBySelector } from '../external';
+
+export default {
+  props: {
+    currentSettings: {
+      type: Object,
+      required: true,
+    },
+    canChangeVisibilityLevel: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    allowedVisibilityOptions: {
+      type: Array,
+      required: false,
+      default: () => [0, 10, 20],
+    },
+    lfsAvailable: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    registryAvailable: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    visibilityHelpPath: {
+      type: String,
+      required: false,
+    },
+    lfsHelpPath: {
+      type: String,
+      required: false,
+    },
+    registryHelpPath: {
+      type: String,
+      required: false,
+    },
+  },
+
+  data() {
+    const defaults = {
+      visibilityOptions,
+      visibilityLevel: visibilityOptions.PUBLIC,
+      issuesAccessLevel: 20,
+      repositoryAccessLevel: 20,
+      mergeRequestsAccessLevel: 20,
+      buildsAccessLevel: 20,
+      wikiAccessLevel: 20,
+      snippetsAccessLevel: 20,
+      containerRegistryEnabled: true,
+      lfsEnabled: true,
+      requestAccessEnabled: true,
+      highlightChangesClass: false,
+    };
+
+    return { ...defaults, ...this.currentSettings };
+  },
+
+  components: {
+    projectFeatureSetting,
+    projectFeatureToggle,
+    projectSettingRow,
+  },
+
+  computed: {
+    featureAccessLevelOptions() {
+      const options = [
+        [10, 'Only Project Members'],
+      ];
+      if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+        options.push([20, 'Everyone With Access']);
+      }
+      return options;
+    },
+
+    repoFeatureAccessLevelOptions() {
+      return this.featureAccessLevelOptions.filter(
+        ([value]) => value <= this.repositoryAccessLevel,
+      );
+    },
+
+    repositoryEnabled() {
+      return this.repositoryAccessLevel > 0;
+    },
+
+    visibilityLevelDescription() {
+      return visibilityLevelDescriptions[this.visibilityLevel];
+    },
+  },
+
+  methods: {
+    highlightChanges() {
+      this.highlightChangesClass = true;
+      this.$nextTick(() => {
+        this.highlightChangesClass = false;
+      });
+    },
+
+    visibilityAllowed(option) {
+      return this.allowedVisibilityOptions.includes(option);
+    },
+  },
+
+  watch: {
+    visibilityLevel(value, oldValue) {
+      if (value === visibilityOptions.PRIVATE) {
+        // when private, features are restricted to "only team members"
+        this.issuesAccessLevel = Math.min(10, this.issuesAccessLevel);
+        this.repositoryAccessLevel = Math.min(10, this.repositoryAccessLevel);
+        this.mergeRequestsAccessLevel = Math.min(10, this.mergeRequestsAccessLevel);
+        this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel);
+        this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel);
+        this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel);
+        this.highlightChanges();
+      } else if (oldValue === visibilityOptions.PRIVATE) {
+        // if changing away from private, make enabled features more permissive
+        if (this.issuesAccessLevel > 0) this.issuesAccessLevel = 20;
+        if (this.repositoryAccessLevel > 0) this.repositoryAccessLevel = 20;
+        if (this.mergeRequestsAccessLevel > 0) this.mergeRequestsAccessLevel = 20;
+        if (this.buildsAccessLevel > 0) this.buildsAccessLevel = 20;
+        if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20;
+        if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20;
+        this.highlightChanges();
+      }
+    },
+
+    repositoryAccessLevel(value, oldValue) {
+      if (value < oldValue) {
+        // sub-features cannot have more premissive access level
+        this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value);
+        this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value);
+
+        if (value === 0) {
+          this.containerRegistryEnabled = false;
+          this.lfsEnabled = false;
+        }
+      } else if (oldValue === 0) {
+        this.mergeRequestsAccessLevel = value;
+        this.buildsAccessLevel = value;
+        this.containerRegistryEnabled = true;
+        this.lfsEnabled = true;
+      }
+    },
+
+    issuesAccessLevel(value, oldValue) {
+      if (value === 0) toggleHiddenClassBySelector('.issues-feature', true);
+      else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false);
+    },
+
+    mergeRequestsAccessLevel(value, oldValue) {
+      if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true);
+      else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false);
+    },
+
+    buildsAccessLevel(value, oldValue) {
+      if (value === 0) toggleHiddenClassBySelector('.builds-feature', true);
+      else if (oldValue === 0) toggleHiddenClassBySelector('.builds-feature', false);
+    },
+  },
+};
+
+</script>
+
+<template>
+  <div>
+    <div class="project-visibility-setting">
+      <project-setting-row
+        label="Project visibility"
+        :help-path="visibilityHelpPath"
+      >
+        <div class="project-feature-controls">
+          <div class="select-wrapper">
+            <select
+              name="project[visibility_level]"
+              v-model="visibilityLevel"
+              class="form-control select-control"
+              :disabled="!canChangeVisibilityLevel"
+            >
+              <option
+                :value="visibilityOptions.PRIVATE"
+                :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+              >
+                Private
+              </option>
+              <option
+                :value="visibilityOptions.INTERNAL"
+                :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
+              >
+                Internal
+              </option>
+              <option
+                :value="visibilityOptions.PUBLIC"
+                :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+              >
+                Public
+              </option>
+            </select>
+            <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
+          </div>
+        </div>
+        <span class="help-block">{{ visibilityLevelDescription }}</span>
+        <label v-if="visibilityLevel !== visibilityOptions.PUBLIC" class="request-access">
+          <input
+            type="hidden"
+            name="project[request_access_enabled]"
+            :value="requestAccessEnabled"
+          />
+          <input type="checkbox" v-model="requestAccessEnabled" />
+          Allow users to request access
+        </label>
+      </project-setting-row>
+    </div>
+    <div class="project-feature-settings" :class="{ 'highlight-changes': highlightChangesClass }">
+      <project-setting-row
+        label="Issues"
+        help-text="Lightweight issue tracking system for this project"
+      >
+        <project-feature-setting
+          name="project[project_feature_attributes][issues_access_level]"
+          :options="featureAccessLevelOptions"
+          v-model="issuesAccessLevel"
+        />
+      </project-setting-row>
+      <project-setting-row
+        label="Repository"
+        help-text="View and edit files in this project"
+      >
+        <project-feature-setting
+          name="project[project_feature_attributes][repository_access_level]"
+          :options="featureAccessLevelOptions"
+          v-model="repositoryAccessLevel"
+        />
+      </project-setting-row>
+      <div class="project-feature-setting-group">
+        <project-setting-row
+          label="Merge requests"
+          help-text="Submit changes to be merged upstream"
+        >
+          <project-feature-setting
+            name="project[project_feature_attributes][merge_requests_access_level]"
+            :options="repoFeatureAccessLevelOptions"
+            v-model="mergeRequestsAccessLevel"
+            :disabledInput="!repositoryEnabled"
+          />
+        </project-setting-row>
+        <project-setting-row
+          label="Pipelines"
+          help-text="Build, test, and deploy your changes"
+        >
+          <project-feature-setting
+            name="project[project_feature_attributes][builds_access_level]"
+            :options="repoFeatureAccessLevelOptions"
+            v-model="buildsAccessLevel"
+            :disabledInput="!repositoryEnabled"
+          />
+        </project-setting-row>
+        <project-setting-row
+          v-if="registryAvailable"
+          label="Container registry"
+          :help-path="registryHelpPath"
+          help-text="Every project can have its own space to store its Docker images"
+        >
+          <project-feature-toggle
+            name="project[container_registry_enabled]"
+            v-model="containerRegistryEnabled"
+            :disabledInput="!repositoryEnabled"
+          />
+        </project-setting-row>
+        <project-setting-row
+          v-if="lfsAvailable"
+          label="Git Large File Storage"
+          :help-path="lfsHelpPath"
+          help-text="Manages large files such as audio, video, and graphics files"
+        >
+          <project-feature-toggle
+            name="project[lfs_enabled]"
+            v-model="lfsEnabled"
+            :disabledInput="!repositoryEnabled"
+          />
+        </project-setting-row>
+      </div>
+      <project-setting-row
+        label="Wiki"
+        help-text="Pages for project documentation"
+      >
+        <project-feature-setting
+          name="project[project_feature_attributes][wiki_access_level]"
+          :options="featureAccessLevelOptions"
+          v-model="wikiAccessLevel"
+        />
+      </project-setting-row>
+      <project-setting-row
+        label="Snippets"
+        help-text="Share code pastes with others out of Git repository"
+      >
+        <project-feature-setting
+          name="project[project_feature_attributes][snippets_access_level]"
+          :options="featureAccessLevelOptions"
+          v-model="snippetsAccessLevel"
+        />
+      </project-setting-row>
+    </div>
+  </div>
+</template>
diff --git a/app/assets/javascripts/projects/permissions/constants.js b/app/assets/javascripts/projects/permissions/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..ce47562f259db48aef2ff8117dde9e6e977c73b4
--- /dev/null
+++ b/app/assets/javascripts/projects/permissions/constants.js
@@ -0,0 +1,11 @@
+export const visibilityOptions = {
+  PRIVATE: 0,
+  INTERNAL: 10,
+  PUBLIC: 20,
+};
+
+export const visibilityLevelDescriptions = {
+  [visibilityOptions.PRIVATE]: 'The project is accessible only by members of the project. Access must be granted explicitly to each user.',
+  [visibilityOptions.INTERNAL]: 'The project can be accessed by any user who is logged in.',
+  [visibilityOptions.PUBLIC]: 'The project can be accessed by anyone, regardless of authentication.',
+};
diff --git a/app/assets/javascripts/projects/permissions/external.js b/app/assets/javascripts/projects/permissions/external.js
new file mode 100644
index 0000000000000000000000000000000000000000..460af4a211132aeabecdfb3894f88fb782fb07f7
--- /dev/null
+++ b/app/assets/javascripts/projects/permissions/external.js
@@ -0,0 +1,18 @@
+const selectorCache = [];
+
+// workaround since we don't have a polyfill for classList.toggle 2nd parameter
+export function toggleHiddenClass(element, hidden) {
+  if (hidden) {
+    element.classList.add('hidden');
+  } else {
+    element.classList.remove('hidden');
+  }
+}
+
+// hide external feature-specific settings when a given feature is disabled
+export function toggleHiddenClassBySelector(selector, hidden) {
+  if (!selectorCache[selector]) {
+    selectorCache[selector] = document.querySelectorAll(selector);
+  }
+  selectorCache[selector].forEach(elm => toggleHiddenClass(elm, hidden));
+}
diff --git a/app/assets/javascripts/projects/permissions/index.js b/app/assets/javascripts/projects/permissions/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..dbde8dda63418c9460a39195cb6a1ce983cd53ed
--- /dev/null
+++ b/app/assets/javascripts/projects/permissions/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import settingsPanel from './components/settings_panel.vue';
+
+export default function initProjectPermissionsSettings() {
+  const mountPoint = document.querySelector('.js-project-permissions-form');
+  const componentPropsEl = document.querySelector('.js-project-permissions-form-data');
+  const componentProps = JSON.parse(componentPropsEl.innerHTML);
+
+  return new Vue({
+    el: mountPoint,
+    render: createElement => createElement(settingsPanel, { props: { ...componentProps } }),
+  });
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index becdd7ff35b20945ecca3819d84c37e761716563..3857226cddb543d5ad76c511f457d271f3a9400a 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -579,6 +579,11 @@ $project-breadcrumb-color: #999;
 $project-private-forks-notice-odd: $green-600;
 $project-network-controls-color: #888;
 
+$feature-toggle-color: #fff;
+$feature-toggle-text-color: #fff;
+$feature-toggle-color-disabled: #999;
+$feature-toggle-color-enabled: #4a8bee;
+
 /*
 * Runners
 */
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index dd600a275455ba9ea7011f48b65bfb75d79fe0bb..94e4f4334d489bbf3e9d1ce5b087e58dfabe1f7a 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -10,41 +10,6 @@
 .edit-project,
 .import-project {
 
-  .sharing-and-permissions {
-    .header {
-      padding-top: $gl-vert-padding;
-    }
-
-    .label-light {
-      margin-bottom: 0;
-    }
-
-    .help-block {
-      margin-top: 0;
-    }
-
-    .form-group {
-      margin-bottom: 5px;
-    }
-
-    > .form-group {
-      padding-left: 0;
-    }
-
-    select option[disabled] {
-      display: none;
-    }
-  }
-
-  select {
-    transition: background 2s ease-out;
-
-    &.highlight-changes {
-      background: $highlight-changes-color;
-      transition: none;
-    }
-  }
-
   .help-block {
     margin-bottom: 10px;
   }
@@ -90,6 +55,162 @@
   }
 }
 
+.toggle-wrapper {
+  margin-top: 5px;
+}
+
+.project-feature-row > .toggle-wrapper {
+  margin: 10px 0;
+}
+
+.project-visibility-setting,
+.project-feature-settings {
+  border: 1px solid $border-color;
+  padding: 10px 32px;
+
+  @media (max-width: $screen-xs-min) {
+    padding: 10px 20px;
+  }
+}
+
+.project-visibility-setting .request-access {
+  line-height: 2;
+}
+
+.project-feature-settings {
+  background: $gray-lighter;
+  border-top: none;
+  margin-bottom: 16px;
+}
+
+.project-repo-select {
+  transition: background 2s ease-out;
+
+  &:disabled {
+    opacity: 0.75;
+  }
+
+  .highlight-changes & {
+    background: $highlight-changes-color;
+    transition: none;
+  }
+}
+
+.project-feature-controls {
+  display: flex;
+  align-items: center;
+  margin: 8px 0;
+  max-width: 432px;
+
+  .toggle-wrapper {
+    flex: 0;
+    margin-right: 10px;
+  }
+
+  .select-wrapper {
+    flex: 1;
+  }
+}
+
+.project-feature-setting-group {
+  padding-left: 32px;
+
+  .project-feature-controls {
+    max-width: 400px;
+  }
+
+  @media (max-width: $screen-xs-min) {
+    padding-left: 20px;
+  }
+}
+
+.project-feature-toggle {
+  position: relative;
+  border: none;
+  outline: 0;
+  display: block;
+  width: 100px;
+  height: 24px;
+  cursor: pointer;
+  user-select: none;
+  background: $feature-toggle-color-disabled;
+  border-radius: 12px;
+  padding: 3px;
+  transition: all .4s ease;
+
+  &::selection,
+  &::before::selection,
+  &::after::selection {
+    background: none;
+  }
+
+  &::before {
+    color: $feature-toggle-text-color;
+    font-size: 12px;
+    line-height: 24px;
+    position: absolute;
+    top: 0;
+    left: 25px;
+    right: 5px;
+    text-align: center;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    animation: animate-disabled .2s ease-in;
+    content: attr(data-disabled-text);
+  }
+
+  &::after {
+    position: relative;
+    display: block;
+    content: "";
+    width: 22px;
+    height: 18px;
+    left: 0;
+    border-radius: 9px;
+    background: $feature-toggle-color;
+    transition: all .2s ease;
+  }
+
+  &.checked {
+    background: $feature-toggle-color-enabled;
+
+    &::before {
+      left: 5px;
+      right: 25px;
+      animation: animate-enabled .2s ease-in;
+      content: attr(data-enabled-text);
+    }
+
+    &::after {
+      left: calc(100% - 22px);
+    }
+  }
+
+  &.disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+  }
+
+  @media (max-width: $screen-xs-min) {
+    width: 50px;
+
+    &::before,
+    &.checked::before {
+      display: none;
+    }
+  }
+
+  @keyframes animate-enabled {
+    0%, 35% { opacity: 0; }
+    100% { opacity: 1; }
+  }
+
+  @keyframes animate-disabled {
+    0%, 35% { opacity: 0; }
+    100% { opacity: 1; }
+  }
+}
+
 .project-home-panel,
 .group-home-panel {
   padding-top: 24px;
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 51c625ede4b65093ea0308399d0a3ef519fccc1d..c0114dd025641c1b1b90afb8061019c8d78905af 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -545,6 +545,43 @@ module ProjectsHelper
     current_application_settings.restricted_visibility_levels || []
   end
 
+  def project_permissions_settings(project)
+    feature = project.project_feature
+    {
+      visibilityLevel: project.visibility_level,
+      requestAccessEnabled: !!project.request_access_enabled,
+      issuesAccessLevel: feature.issues_access_level,
+      repositoryAccessLevel: feature.repository_access_level,
+      mergeRequestsAccessLevel: feature.merge_requests_access_level,
+      buildsAccessLevel: feature.builds_access_level,
+      wikiAccessLevel: feature.wiki_access_level,
+      snippetsAccessLevel: feature.snippets_access_level,
+      containerRegistryEnabled: !!project.container_registry_enabled,
+      lfsEnabled: !!project.lfs_enabled
+    }
+  end
+
+  def project_permissions_panel_data(project)
+    data = {
+      currentSettings: project_permissions_settings(project),
+      canChangeVisibilityLevel: can_change_visibility_level?(project, current_user),
+      allowedVisibilityOptions: project_allowed_visibility_levels(project),
+      visibilityHelpPath: help_page_path('public_access/public_access'),
+      registryAvailable: Gitlab.config.registry.enabled,
+      registryHelpPath: help_page_path('user/project/container_registry'),
+      lfsAvailable: Gitlab.config.lfs.enabled && current_user.admin?,
+      lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
+    }
+
+    data.to_json.html_safe
+  end
+
+  def project_allowed_visibility_levels(project)
+    Gitlab::VisibilityLevel.values.select do |level|
+      project.visibility_level_allowed?(level) && !restricted_levels.include?(level)
+    end
+  end
+
   def find_file_path
     return unless @project && !@project.empty_repo?
 
diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml
index e3effe45d6c7367ad6d08da259644822af1c262d..1dd8778f8007c2d93774ec5390c7cbc1c81137dd 100644
--- a/app/views/projects/_merge_request_merge_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_settings.html.haml
@@ -1,7 +1,7 @@
 - form = local_assigns.fetch(:form)
 
 .form-group
-  .checkbox.builds-feature
+  .checkbox.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) }
     = form.label :only_allow_merge_if_pipeline_succeeds do
       = form.check_box :only_allow_merge_if_pipeline_succeeds
       %strong Only allow merge requests to be merged if the pipeline succeeds
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 994119051d273f7ee7819e57f4cebd7e3064b3e9..0a3045604f4a1b3cd3c5b6d0aa6623e78b7dbe94 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -66,90 +66,18 @@
   %section.settings.sharing-permissions
     .settings-header
       %h4
-        Sharing and permissions
+        Permissions
       %button.btn.js-settings-toggle
         = expanded ? 'Collapse' : 'Expand'
       %p
         Enable or disable certain project features and choose access levels.
     .settings-content.no-animate{ class: ('expanded' if expanded) }
       = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
-        .form_group.sharing-and-permissions
-          .row.js-visibility-select
-            .col-md-8
-              .label-light
-                = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level
-                = link_to icon('question-circle'), help_page_path("public_access/public_access")
-              %span.help-block
-            .col-md-4.visibility-select-container
-              = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
-          = f.fields_for :project_feature do |feature_fields|
-            %fieldset.features
-              .row
-                .col-md-8.project-feature
-                  = feature_fields.label :repository_access_level, "Repository", class: 'label-light'
-                  %span.help-block View and edit files in this project
-                .col-md-4.js-repo-access-level
-                  = project_feature_access_select(:repository_access_level)
-
-              .row
-                .col-md-8.project-feature.nested
-                  = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
-                  %span.help-block Submit changes to be merged upstream
-                .col-md-4
-                  = project_feature_access_select(:merge_requests_access_level)
-
-              .row
-                .col-md-8.project-feature.nested
-                  = feature_fields.label :builds_access_level, "Pipelines", class: 'label-light'
-                  %span.help-block Build, test, and deploy your changes
-                .col-md-4
-                  = project_feature_access_select(:builds_access_level)
-
-              .row
-                .col-md-8.project-feature
-                  = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
-                  %span.help-block Share code pastes with others out of Git repository
-                .col-md-4
-                  = project_feature_access_select(:snippets_access_level)
-
-              .row
-                .col-md-8.project-feature
-                  = feature_fields.label :issues_access_level, "Issues", class: 'label-light'
-                  %span.help-block Lightweight issue tracking system for this project
-                .col-md-4
-                  = project_feature_access_select(:issues_access_level)
-
-              .row
-                .col-md-8.project-feature
-                  = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
-                  %span.help-block Pages for project documentation
-                .col-md-4
-                  = project_feature_access_select(:wiki_access_level)
-        .form-group
-          = render 'shared/allow_request_access', form: f
-        - if Gitlab.config.lfs.enabled && current_user.admin?
-          .row.js-lfs-enabled.form-group.sharing-and-permissions
-            .col-md-8
-              = f.label :lfs_enabled, 'Git Large File Storage', class: 'label-light'
-              = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
-              %span.help-block Manages large files such as audio, video and graphics files.
-            .col-md-4
-              .select-wrapper
-                = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select select-control', data: { field: 'lfs_enabled' }
-                = icon('chevron-down')
-        - if Gitlab.config.registry.enabled
-          .form-group.js-container-registry{ style: ("display: none;" if @project.project_feature.send(:repository_access_level) == 0) }
-            .checkbox
-              = f.label :container_registry_enabled do
-                = f.check_box :container_registry_enabled
-                %strong Container Registry
-                %br
-                %span.descr Enable Container Registry for this project
-                = link_to icon('question-circle'), help_page_path('user/project/container_registry'), target: '_blank'
+        %script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project)
+        .js-project-permissions-form
         = f.submit 'Save changes', class: "btn btn-save"
 
-
-  %section.settings.merge-requests-feature{ style: ("display: none;" if @project.project_feature.send(:merge_requests_access_level) == 0) }
+  %section.settings.merge-requests-feature{ class: ("hidden" if @project.project_feature.send(:merge_requests_access_level) == 0) }
     .settings-header
       %h4
         Merge request settings
diff --git a/changelogs/unreleased/32665-refactor-project-visibility-settings.yml b/changelogs/unreleased/32665-refactor-project-visibility-settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fde70c47ca654630c57a5b561f75d9ae6a649aea
--- /dev/null
+++ b/changelogs/unreleased/32665-refactor-project-visibility-settings.yml
@@ -0,0 +1,5 @@
+---
+title: Redesign project feature permissions settings
+merge_request: 14062
+author:
+type: changed
diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb
index 0a89c1baf206b5967c9aef7ef1a304172ccf0d52..3a762be8f1f5df290fd3b505ba64cbe24dbb10ba 100644
--- a/features/steps/project/project.rb
+++ b/features/steps/project/project.rb
@@ -6,7 +6,6 @@ class Spinach::Features::Project < Spinach::FeatureSteps
 
   step 'change project settings' do
     fill_in 'project_name_edit', with: 'NewName'
-    select 'Disabled', from: 'project_project_feature_attributes_issues_access_level'
   end
 
   step 'I save project' do
diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb
index 605c9a3ab715294af5c70b87c0890b44a70c1679..96cc0745e9763286b15c29c13bb1efb90f6efc31 100644
--- a/features/steps/shared/project.rb
+++ b/features/steps/shared/project.rb
@@ -89,7 +89,7 @@ module SharedProject
   step 'I should see project settings' do
     expect(current_path).to eq edit_project_path(@project)
     expect(page).to have_content("Project name")
-    expect(page).to have_content("Sharing and permissions")
+    expect(page).to have_content("Permissions")
   end
 
   def current_project
diff --git a/spec/features/projects/edit_spec.rb b/spec/features/projects/edit_spec.rb
index d3b1d1f7be3b9a786bbd348d5e23d7c9dc9b8f94..17f914c9c17b83d0994ecb1ed710f6cb06fd574d 100644
--- a/spec/features/projects/edit_spec.rb
+++ b/spec/features/projects/edit_spec.rb
@@ -1,20 +1,21 @@
 require 'rails_helper'
 
 feature 'Project edit', js: true do
+  let(:admin)   { create(:admin) }
   let(:user)    { create(:user) }
   let(:project) { create(:project) }
 
-  before do
-    project.team << [user, :master]
-    sign_in(user)
+  context 'feature visibility' do
+    before do
+      project.team << [user, :master]
+      sign_in(user)
 
-    visit edit_project_path(project)
-  end
+      visit edit_project_path(project)
+    end
 
-  context 'feature visibility' do
     context 'merge requests select' do
       it 'hides merge requests section' do
-        select('Disabled', from: 'project_project_feature_attributes_merge_requests_access_level')
+        find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .project-feature-toggle').click
 
         expect(page).to have_selector('.merge-requests-feature', visible: false)
       end
@@ -30,7 +31,7 @@ feature 'Project edit', js: true do
 
     context 'builds select' do
       it 'hides builds select section' do
-        select('Disabled', from: 'project_project_feature_attributes_builds_access_level')
+        find('.project-feature-controls[data-for="project[project_feature_attributes][builds_access_level]"] .project-feature-toggle').click
 
         expect(page).to have_selector('.builds-feature', visible: false)
       end
@@ -44,4 +45,18 @@ feature 'Project edit', js: true do
       end
     end
   end
+
+  context 'LFS enabled setting' do
+    before do
+      sign_in(admin)
+    end
+
+    it 'displays the correct elements' do
+      allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+      visit edit_project_path(project)
+
+      expect(page).to have_content('Git Large File Storage')
+      expect(page).to have_selector('input[name="project[lfs_enabled]"] + button', visible: true)
+    end
+  end
 end
diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb
index 2469162906330ecb54afa260ce668497f421af2d..57722276d79e5b9675b42fa65b5b5aef2b7becc8 100644
--- a/spec/features/projects/features_visibility_spec.rb
+++ b/spec/features/projects/features_visibility_spec.rb
@@ -19,21 +19,16 @@ describe 'Edit Project Settings' do
         it 'toggles visibility' do
           visit edit_project_path(project)
 
-          select 'Disabled', from: "project_project_feature_attributes_#{tool_name}_access_level"
+          # disable by clicking toggle
+          toggle_feature_off("project[project_feature_attributes][#{tool_name}_access_level]")
           page.within('.sharing-permissions') do
             click_button 'Save changes'
           end
           wait_for_requests
           expect(page).not_to have_selector(".shortcuts-#{shortcut_name}")
 
-          select 'Everyone with access', from: "project_project_feature_attributes_#{tool_name}_access_level"
-          page.within('.sharing-permissions') do
-            click_button 'Save changes'
-          end
-          wait_for_requests
-          expect(page).to have_selector(".shortcuts-#{shortcut_name}")
-
-          select 'Only team members', from: "project_project_feature_attributes_#{tool_name}_access_level"
+          # re-enable by clicking toggle again
+          toggle_feature_on("project[project_feature_attributes][#{tool_name}_access_level]")
           page.within('.sharing-permissions') do
             click_button 'Save changes'
           end
@@ -176,19 +171,19 @@ describe 'Edit Project Settings' do
     end
 
     it "disables repository related features" do
-      select "Disabled", from: "project_project_feature_attributes_repository_access_level"
+      toggle_feature_off('project[project_feature_attributes][repository_access_level]')
 
       page.within('.sharing-permissions') do
         click_button "Save changes"
       end
 
-      expect(find(".sharing-permissions")).to have_selector("select.disabled", count: 2)
+      expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.disabled", count: 2)
     end
 
     it "shows empty features project homepage" do
-      select "Disabled", from: "project_project_feature_attributes_repository_access_level"
-      select "Disabled", from: "project_project_feature_attributes_issues_access_level"
-      select "Disabled", from: "project_project_feature_attributes_wiki_access_level"
+      toggle_feature_off('project[project_feature_attributes][repository_access_level]')
+      toggle_feature_off('project[project_feature_attributes][issues_access_level]')
+      toggle_feature_off('project[project_feature_attributes][wiki_access_level]')
 
       page.within('.sharing-permissions') do
         click_button "Save changes"
@@ -201,9 +196,9 @@ describe 'Edit Project Settings' do
     end
 
     it "hides project activity tabs" do
-      select "Disabled", from: "project_project_feature_attributes_repository_access_level"
-      select "Disabled", from: "project_project_feature_attributes_issues_access_level"
-      select "Disabled", from: "project_project_feature_attributes_wiki_access_level"
+      toggle_feature_off('project[project_feature_attributes][repository_access_level]')
+      toggle_feature_off('project[project_feature_attributes][issues_access_level]')
+      toggle_feature_off('project[project_feature_attributes][wiki_access_level]')
 
       page.within('.sharing-permissions') do
         click_button "Save changes"
@@ -222,7 +217,7 @@ describe 'Edit Project Settings' do
 
     # Regression spec for https://gitlab.com/gitlab-org/gitlab-ce/issues/25272
     it "hides comments activity tab only on disabled issues, merge requests and repository" do
-      select "Disabled", from: "project_project_feature_attributes_issues_access_level"
+      toggle_feature_off('project[project_feature_attributes][issues_access_level]')
 
       save_changes_and_check_activity_tab do
         expect(page).to have_content("Comments")
@@ -230,7 +225,7 @@ describe 'Edit Project Settings' do
 
       visit edit_project_path(project)
 
-      select "Disabled", from: "project_project_feature_attributes_merge_requests_access_level"
+      toggle_feature_off('project[project_feature_attributes][merge_requests_access_level]')
 
       save_changes_and_check_activity_tab do
         expect(page).to have_content("Comments")
@@ -238,7 +233,7 @@ describe 'Edit Project Settings' do
 
       visit edit_project_path(project)
 
-      select "Disabled", from: "project_project_feature_attributes_repository_access_level"
+      toggle_feature_off('project[project_feature_attributes][repository_access_level]')
 
       save_changes_and_check_activity_tab do
         expect(page).not_to have_content("Comments")
@@ -275,4 +270,12 @@ describe 'Edit Project Settings' do
       expect(page).not_to have_selector('.project-stats')
     end
   end
+
+  def toggle_feature_off(feature_name)
+    find(".project-feature-controls[data-for=\"#{feature_name}\"] .project-feature-toggle.checked").click
+  end
+
+  def toggle_feature_on(feature_name)
+    find(".project-feature-controls[data-for=\"#{feature_name}\"] .project-feature-toggle:not(.checked)").click
+  end
 end
diff --git a/spec/features/projects/settings/merge_requests_settings_spec.rb b/spec/features/projects/settings/merge_requests_settings_spec.rb
index 104ce08d9f3cef1694f82a7c2bde13eca77c92aa..b1ec556bf1651720d3a9b2dcc22e751e97f6552b 100644
--- a/spec/features/projects/settings/merge_requests_settings_spec.rb
+++ b/spec/features/projects/settings/merge_requests_settings_spec.rb
@@ -19,8 +19,8 @@ feature 'Project settings > Merge Requests', :js do
         expect(page).to have_content('Only allow merge requests to be merged if the pipeline succeeds')
         expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
 
-        select 'Disabled', from: "project_project_feature_attributes_merge_requests_access_level"
         within('.sharing-permissions-form') do
+          find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .project-feature-toggle').click
           click_on('Save changes')
         end
 
@@ -39,8 +39,8 @@ feature 'Project settings > Merge Requests', :js do
         expect(page).not_to have_content('Only allow merge requests to be merged if the pipeline succeeds')
         expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved')
 
-        select 'Everyone with access', from: "project_project_feature_attributes_builds_access_level"
         within('.sharing-permissions-form') do
+          find('.project-feature-controls[data-for="project[project_feature_attributes][builds_access_level]"] .project-feature-toggle').click
           click_on('Save changes')
         end
 
@@ -60,8 +60,8 @@ feature 'Project settings > Merge Requests', :js do
       expect(page).not_to have_content('Only allow merge requests to be merged if the pipeline succeeds')
       expect(page).not_to have_content('Only allow merge requests to be merged if all discussions are resolved')
 
-      select 'Everyone with access', from: "project_project_feature_attributes_merge_requests_access_level"
       within('.sharing-permissions-form') do
+        find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .project-feature-toggle').click
         click_on('Save changes')
       end
 
diff --git a/spec/features/projects/settings/visibility_settings_spec.rb b/spec/features/projects/settings/visibility_settings_spec.rb
index 1756c7d00fe95a2901152e84ee10575ef3c3c5ae..37ee6255bd1420e5226c377634f27e9e9e5499f0 100644
--- a/spec/features/projects/settings/visibility_settings_spec.rb
+++ b/spec/features/projects/settings/visibility_settings_spec.rb
@@ -11,19 +11,19 @@ feature 'Visibility settings', js: true do
     end
 
     scenario 'project visibility select is available' do
-      visibility_select_container = find('.js-visibility-select')
+      visibility_select_container = find('.project-visibility-setting')
 
-      expect(visibility_select_container.find('.visibility-select').value).to eq project.visibility_level.to_s
-      expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.'
+      expect(visibility_select_container.find('select').value).to eq project.visibility_level.to_s
+      expect(visibility_select_container).to have_content 'The project can be accessed by anyone, regardless of authentication.'
     end
 
     scenario 'project visibility description updates on change' do
-      visibility_select_container = find('.js-visibility-select')
-      visibility_select = visibility_select_container.find('.visibility-select')
+      visibility_select_container = find('.project-visibility-setting')
+      visibility_select = visibility_select_container.find('select')
       visibility_select.select('Private')
 
       expect(visibility_select.value).to eq '0'
-      expect(visibility_select_container).to have_content 'Project access must be granted explicitly to each user.'
+      expect(visibility_select_container).to have_content 'Access must be granted explicitly to each user.'
     end
   end
 
@@ -37,11 +37,10 @@ feature 'Visibility settings', js: true do
     end
 
     scenario 'project visibility is locked' do
-      visibility_select_container = find('.js-visibility-select')
+      visibility_select_container = find('.project-visibility-setting')
 
-      expect(visibility_select_container).not_to have_select '.visibility-select'
-      expect(visibility_select_container).to have_content 'Public'
-      expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.'
+      expect(visibility_select_container).to have_selector 'select[name="project[visibility_level]"]:disabled'
+      expect(visibility_select_container).to have_content 'The project can be accessed by anyone, regardless of authentication.'
     end
   end
 end
diff --git a/spec/views/projects/edit.html.haml_spec.rb b/spec/views/projects/edit.html.haml_spec.rb
index c139862974916f7bb6d7c5552bf5d8f7bdecb3ce..5c6b2e4b04251e446098c6742682c2b3e1c39f44 100644
--- a/spec/views/projects/edit.html.haml_spec.rb
+++ b/spec/views/projects/edit.html.haml_spec.rb
@@ -15,17 +15,6 @@ describe 'projects/edit' do
                                     current_application_settings: Gitlab::CurrentSettings.current_application_settings)
   end
 
-  context 'LFS enabled setting' do
-    it 'displays the correct elements' do
-      allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
-
-      render
-
-      expect(rendered).to have_select('project_lfs_enabled')
-      expect(rendered).to have_content('Git Large File Storage')
-    end
-  end
-
   context 'project export disabled' do
     it 'does not display the project export option' do
       stub_application_setting(project_export_enabled?: false)