diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
index 4581b508fe8b6f462e86a39916dc940a20dabc39..beb79d027c4cee8f42cf4c08f07344618846f36e 100644
--- a/app/assets/javascripts/ide/init_gitlab_web_ide.js
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -46,6 +46,7 @@ export const initGitlabWebIDE = async (el) => {
     forkInfo: forkInfoJSON,
     editorFont: editorFontJSON,
     codeSuggestionsEnabled,
+    extensionsGallerySettings: extensionsGallerySettingsJSON,
   } = el.dataset;
 
   const rootEl = setupRootElement(el);
@@ -53,6 +54,9 @@ export const initGitlabWebIDE = async (el) => {
     ? convertObjectPropsToCamelCase(JSON.parse(editorFontJSON), { deep: true })
     : null;
   const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null;
+  const extensionsGallerySettings = extensionsGallerySettingsJSON
+    ? convertObjectPropsToCamelCase(JSON.parse(extensionsGallerySettingsJSON), { deep: true })
+    : undefined;
 
   const oauthConfig = getOAuthConfig(el.dataset);
   const httpHeaders = oauthConfig
@@ -85,6 +89,7 @@ export const initGitlabWebIDE = async (el) => {
       settingsSync: true,
     },
     editorFont,
+    extensionsGallerySettings,
     codeSuggestionsEnabled,
     handleTracking,
     // See https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L86
diff --git a/app/assets/javascripts/profile/preferences/components/extensions_marketplace_warning.vue b/app/assets/javascripts/profile/preferences/components/extensions_marketplace_warning.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1dd7bc8352da549836eed1aa063990acefc580a6
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/components/extensions_marketplace_warning.vue
@@ -0,0 +1,130 @@
+<script>
+import { GlIcon, GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export const WARNING_PARAGRAPH_1 = s__(
+  'PreferencesIntegrations|Third-party extensions are now available in the Web IDE. While each extension runs in a secure browser sandbox, %{boldStart}third-party extensions%{boldEnd} may have access to the contents of the files opened in the Web IDE, %{boldStart}including any personal data in those files%{boldEnd}, and may communicate with external servers.',
+);
+
+export const WARNING_PARAGRAPH_2 = s__(
+  'PreferencesIntegrations|GitLab does not assume any responsibility for the functionality of these third-party extensions. Each %{boldStart}third-party%{boldEnd} extension has %{boldStart}their own independent%{boldEnd} terms and conditions listed in the marketplace. By installing an extension, you are agreeing to the terms & conditions and Privacy Policy that govern each individual extension as listed in the marketplace.',
+);
+
+export const WARNING_PARAGRAPH_3 = s__(
+  'PreferencesIntegrations|By using the Extension Marketplace, you will send data, such as IP address and other device information, to %{url} in accordance with their independent terms and privacy policy.',
+);
+
+export default {
+  components: {
+    GlIcon,
+    GlLink,
+    GlModal,
+    GlSprintf,
+  },
+  inject: {
+    extensionsMarketplaceUrl: {
+      default: '',
+    },
+  },
+  props: {
+    value: {
+      type: Boolean,
+      required: true,
+    },
+    helpUrl: {
+      type: String,
+      required: false,
+      default: '',
+    },
+  },
+  data() {
+    return {
+      // If we have already enabled, let's consider warning not necessary
+      needsWarning: !this.value,
+      showWarning: false,
+    };
+  },
+  computed: {
+    actionSecondary() {
+      if (!this.helpUrl) {
+        return undefined;
+      }
+
+      return {
+        text: s__('PreferencesIntegrations|Learn more'),
+        attributes: {
+          href: this.helpUrl,
+          variant: 'default',
+        },
+      };
+    },
+  },
+  watch: {
+    value(val) {
+      // Have we tried to accept but we need to show the warning?
+      if (val && this.needsWarning) {
+        this.showWarning = true;
+      }
+    },
+    async showWarning(val) {
+      // Wait a bit so that `needsWarning` is properly updated if accepting.
+      await this.$nextTick();
+
+      // If we are showing the warning, the value should be false. Don't treat the user as accepting yet.
+      if (val) {
+        this.$emit('input', false);
+      } else if (!this.needsWarning) {
+        this.$emit('input', true);
+      }
+    },
+  },
+  methods: {
+    onPrimary() {
+      this.needsWarning = false;
+    },
+  },
+  actionPrimary: {
+    text: s__('PreferencesIntegrations|I understand'),
+  },
+  TITLE: s__('PreferencesIntegrations|Third-Party Extensions Acknowledgement'),
+  WARNING_PARAGRAPH_1,
+  WARNING_PARAGRAPH_2,
+  WARNING_PARAGRAPH_3,
+};
+</script>
+
+<template>
+  <gl-modal
+    v-model="showWarning"
+    modal-id="extensions-marketplace-warning-modal"
+    :title="$options.TITLE"
+    :action-primary="$options.actionPrimary"
+    :action-secondary="actionSecondary"
+    @primary="onPrimary"
+  >
+    <p>
+      <gl-sprintf :message="$options.WARNING_PARAGRAPH_1">
+        <template #bold="{ content }">
+          <span class="gl-font-weight-bold">{{ content }}</span>
+        </template>
+      </gl-sprintf>
+    </p>
+    <p>
+      <gl-sprintf :message="$options.WARNING_PARAGRAPH_2">
+        <template #bold="{ content }">
+          <span class="gl-font-weight-bold">{{ content }}</span>
+        </template>
+      </gl-sprintf>
+    </p>
+    <p>
+      <gl-sprintf v-if="extensionsMarketplaceUrl" :message="$options.WARNING_PARAGRAPH_3">
+        <template #url>
+          <gl-link :href="extensionsMarketplaceUrl" target="_blank"
+            >{{ extensionsMarketplaceUrl }}
+            <gl-icon name="external-link" class="gl-align-middle" :size="12" />
+          </gl-link>
+        </template>
+      </gl-sprintf>
+    </p>
+  </gl-modal>
+</template>
diff --git a/app/assets/javascripts/profile/preferences/components/integration_view.vue b/app/assets/javascripts/profile/preferences/components/integration_view.vue
index 9924f248b891ab892e51d009e28c544bdc3622d3..06f8459e637cc36d315ef160cc4e6604f956c278 100644
--- a/app/assets/javascripts/profile/preferences/components/integration_view.vue
+++ b/app/assets/javascripts/profile/preferences/components/integration_view.vue
@@ -2,6 +2,8 @@
 import { GlIcon, GlLink, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
 import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
 
+const toCheckboxValue = (bool) => (bool ? '1' : false);
+
 export default {
   name: 'IntegrationView',
   components: {
@@ -11,7 +13,6 @@ export default {
     GlFormCheckbox,
     IntegrationHelpText,
   },
-  inject: ['userFields'],
   props: {
     helpLink: {
       type: String,
@@ -29,10 +30,14 @@ export default {
       type: Object,
       required: true,
     },
+    value: {
+      type: Boolean,
+      required: true,
+    },
   },
   data() {
     return {
-      isEnabled: this.userFields[this.config.formName] ? '1' : '0',
+      checkboxValue: toCheckboxValue(this.value),
     };
   },
   computed: {
@@ -43,6 +48,17 @@ export default {
       return `user_${this.config.formName}`;
     },
   },
+  watch: {
+    value(val) {
+      this.checkboxValue = toCheckboxValue(val);
+    },
+    checkboxValue(val) {
+      // note: When checked we get '1' since we set `value` prop. Unchecked is `false` as expected.
+      //       This value="1" needs to be set to properly handle the Rails form.
+      //       https://bootstrap-vue.org/docs/components/form-checkbox#comp-ref-b-form-checkbox-props
+      this.$emit('input', Boolean(val));
+    },
+  },
 };
 </script>
 
@@ -61,7 +77,7 @@ export default {
       value="0"
       data-testid="profile-preferences-integration-hidden-field"
     />
-    <gl-form-checkbox :id="formId" :checked="isEnabled" :name="formName" value="1"
+    <gl-form-checkbox :id="formId" v-model="checkboxValue" :name="formName" value="1"
       >{{ config.label }}
       <template #help>
         <integration-help-text :message="message" :message-url="messageUrl" />
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 87296200822829222291be623b7656dfed65eec1..f42ca4cf569d98eb546733a5c625ef7ce4de4f54 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -1,8 +1,9 @@
 <script>
 import { GlButton } from '@gitlab/ui';
 import { createAlert, VARIANT_DANGER } from '~/alert';
-import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants';
+import { INTEGRATION_VIEW_CONFIGS, i18n, INTEGRATION_EXTENSIONS_MARKETPLACE } from '../constants';
 import IntegrationView from './integration_view.vue';
+import ExtensionsMarketplaceWarning from './extensions_marketplace_warning.vue';
 
 function updateClasses(bodyClasses = '', applicationTheme, layout) {
   // Remove documentElement class for any previous theme, re-add current one
@@ -24,6 +25,7 @@ export default {
   components: {
     IntegrationView,
     GlButton,
+    ExtensionsMarketplaceWarning,
   },
   inject: {
     integrationViews: {
@@ -44,13 +46,28 @@ export default {
   },
   integrationViewConfigs: INTEGRATION_VIEW_CONFIGS,
   i18n,
+  INTEGRATION_EXTENSIONS_MARKETPLACE,
   data() {
+    const integrationValues = this.integrationViews.reduce((acc, { name }) => {
+      const { formName } = INTEGRATION_VIEW_CONFIGS[name];
+
+      acc[name] = Boolean(this.userFields[formName]);
+
+      return acc;
+    }, {});
+
     return {
       isSubmitEnabled: true,
       darkModeOnCreate: null,
       schemeOnCreate: null,
+      integrationValues,
     };
   },
+  computed: {
+    extensionsMarketplaceView() {
+      return this.integrationViews.find(({ name }) => name === INTEGRATION_EXTENSIONS_MARKETPLACE);
+    },
+  },
   created() {
     this.formEl.addEventListener('ajax:beforeSend', this.handleLoading);
     this.formEl.addEventListener('ajax:success', this.handleSuccess);
@@ -117,7 +134,11 @@ export default {
     >
       <div class="settings-sticky-header">
         <div class="settings-sticky-header-inner">
-          <h4 class="gl-my-0" data-testid="profile-preferences-integrations-heading">
+          <h4
+            id="integrations"
+            class="gl-my-0"
+            data-testid="profile-preferences-integrations-heading"
+          >
             {{ $options.i18n.integrations }}
           </h4>
         </div>
@@ -129,6 +150,7 @@ export default {
         <integration-view
           v-for="view in integrationViews"
           :key="view.name"
+          v-model="integrationValues[view.name]"
           :help-link="view.help_link"
           :message="view.message"
           :message-url="view.message_url"
@@ -148,5 +170,10 @@ export default {
         {{ $options.i18n.saveChanges }}
       </gl-button>
     </div>
+    <extensions-marketplace-warning
+      v-if="extensionsMarketplaceView"
+      v-model="integrationValues[$options.INTEGRATION_EXTENSIONS_MARKETPLACE]"
+      :help-url="extensionsMarketplaceView.help_link"
+    />
   </div>
 </template>
diff --git a/app/assets/javascripts/profile/preferences/constants.js b/app/assets/javascripts/profile/preferences/constants.js
index ea8464ba06544f3877e4edb5dc0ad4588bc2290b..ab838772729b169107cc35a168693812e61c2383 100644
--- a/app/assets/javascripts/profile/preferences/constants.js
+++ b/app/assets/javascripts/profile/preferences/constants.js
@@ -1,5 +1,7 @@
 import { s__, __ } from '~/locale';
 
+export const INTEGRATION_EXTENSIONS_MARKETPLACE = 'extensions_marketplace';
+
 export const INTEGRATION_VIEW_CONFIGS = {
   sourcegraph: {
     title: s__('Preferences|Sourcegraph'),
@@ -11,6 +13,11 @@ export const INTEGRATION_VIEW_CONFIGS = {
     label: s__('Preferences|Enable Gitpod integration'),
     formName: 'gitpod_enabled',
   },
+  [INTEGRATION_EXTENSIONS_MARKETPLACE]: {
+    title: s__('Preferences|Web IDE'),
+    label: s__('Preferences|Enable extension marketplace'),
+    formName: 'extensions_marketplace_enabled',
+  },
 };
 
 export const i18n = {
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 77ae313b5736e509df189f1314208363053b3c0a..c793d86618216466fee5869e6d06b5ade9dbeb3c 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -56,6 +56,7 @@ def preferences_param_names
       :tab_width,
       :sourcegraph_enabled,
       :gitpod_enabled,
+      :extensions_marketplace_enabled,
       :render_whitespace_in_code,
       :project_shortcut_buttons,
       :keyboard_shortcuts_enabled,
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 312807c004a54733794236809117a168874bc686..e2c41f6da9940bd9875862ecaaa3634bf4e3ee0c 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -71,7 +71,8 @@ def new_ide_data(project:)
       'csp-nonce' => content_security_policy_nonce,
       # We will replace these placeholders in the FE
       'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path'),
-      'editor-font' => new_ide_fonts.to_json
+      'editor-font' => new_ide_fonts.to_json,
+      'extensions-gallery-settings' => extensions_gallery_settings
     }.merge(new_ide_code_suggestions_data).merge(new_ide_oauth_data)
   end
 
@@ -105,4 +106,8 @@ def convert_to_project_entity_json(project)
   def has_dismissed_ide_environments_callout?
     current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
   end
+
+  def extensions_gallery_settings
+    Gitlab::WebIde::ExtensionsMarketplace.webide_extensions_gallery_settings(user: current_user).to_json
+  end
 end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 53d6d35ba8cc4445899e28251be6a6d978e3cf09..6fdfde13a6199d097ab9001805161687d5bcbe70 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -128,11 +128,25 @@ def integration_views
     [].tap do |views|
       views << { name: 'gitpod', message: gitpod_enable_description, message_url: gitpod_url_placeholder, help_link: help_page_path('integration/gitpod') } if Gitlab::CurrentSettings.gitpod_enabled
       views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled
+      views << extensions_marketplace_view if Gitlab::WebIde::ExtensionsMarketplace.feature_enabled?(user: current_user)
     end
   end
 
   private
 
+  def extensions_marketplace_view
+    # We handle the linkStart / linkEnd inside of a Vue sprintf
+    extensions_marketplace_home = "%{linkStart}#{::Gitlab::WebIde::ExtensionsMarketplace.marketplace_home_url}%{linkEnd}"
+    message = format(s_('PreferencesIntegrations|Uses %{extensions_marketplace_home} as the extension marketplace for the Web IDE.'), extensions_marketplace_home: extensions_marketplace_home)
+
+    {
+      name: 'extensions_marketplace',
+      message: message,
+      message_url: Gitlab::WebIde::ExtensionsMarketplace.marketplace_home_url,
+      help_link: Gitlab::WebIde::ExtensionsMarketplace.help_preferences_url
+    }
+  end
+
   def gitpod_url_placeholder
     Gitlab::CurrentSettings.gitpod_url.presence || 'https://gitpod.io/'
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index a1d75f2d080053c69b13542d3be18ca9b2e9d5dd..89daea0e804b9d7eb4426d350b6b5752f3aa505f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -410,6 +410,7 @@ def update_tracked_fields!(request)
     :gitpod_enabled, :gitpod_enabled=,
     :use_web_ide_extension_marketplace, :use_web_ide_extension_marketplace=,
     :extensions_marketplace_opt_in_status, :extensions_marketplace_opt_in_status=,
+    :extensions_marketplace_enabled, :extensions_marketplace_enabled=,
     :setup_for_company, :setup_for_company=,
     :project_shortcut_buttons, :project_shortcut_buttons=,
     :keyboard_shortcuts_enabled, :keyboard_shortcuts_enabled=,
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 5845b521e14dba5f4ec6c9a15d91ad6e957007d9..73162ba6e8434055c9515fbe9e14222923e3d020 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -131,6 +131,18 @@ def early_access_event_tracking?
     early_access_program_participant? && early_access_program_tracking?
   end
 
+  # NOTE: Despite this returning a boolean, it does not end in `?` out of
+  #       symmetry with the other integration fields like `gitpod_enabled`
+  def extensions_marketplace_enabled
+    extensions_marketplace_opt_in_status == "enabled"
+  end
+
+  def extensions_marketplace_enabled=(value)
+    status = ActiveRecord::Type::Boolean.new.cast(value) ? 'enabled' : 'disabled'
+
+    self.extensions_marketplace_opt_in_status = status
+  end
+
   private
 
   def user_belongs_to_home_organization
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 5065f27f40333b6d7afa71285ec210f7c1bd940b..a7a44db96cd6a4f0fe8223267ad3796b4a72e5d8 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -3,12 +3,13 @@
 - user_theme_id = Gitlab::Themes.for_user(@user).id
 - user_color_mode_id = Gitlab::ColorModes.for_user(@user).id
 - user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id
-- user_fields = { color_mode_id: user_color_mode_id, theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json
+- user_fields = { color_mode_id: user_color_mode_id, theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled, extensions_marketplace_enabled: @user.extensions_marketplace_enabled }.to_json
 - fixed_help_text = s_('Preferences|Content will be a maximum of 1280 pixels wide.')
 - fluid_help_text = s_('Preferences|Content will span %{percentage} of the page width.').html_safe % { percentage: '100%' }
 - @color_modes = Gitlab::ColorModes::available_modes.to_json
 - @themes = Gitlab::Themes::available_themes.to_json
-- data_attributes = { color_modes: @color_modes, themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path }
+- extensions_marketplace_url = ::Gitlab::WebIde::ExtensionsMarketplace.marketplace_home_url
+- data_attributes = { color_modes: @color_modes, themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path, extensions_marketplace_url: extensions_marketplace_url }
 - @force_desktop_expanded_sidebar = true
 
 = gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
diff --git a/config/feature_flags/beta/web_ide_extensions_marketplace.yml b/config/feature_flags/beta/web_ide_extensions_marketplace.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ba76b1d3fb598d7acef9c41af88c2884ee9724a6
--- /dev/null
+++ b/config/feature_flags/beta/web_ide_extensions_marketplace.yml
@@ -0,0 +1,9 @@
+---
+name: web_ide_extensions_marketplace
+feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/7685
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151352
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/459028
+milestone: '17.0'
+group: group::ide
+type: beta
+default_enabled: false
diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md
index 78f18b2465528ce6b452a5fa954e3c37505c3ec2..573a3571807c16949fed082d87adfe794ae29406 100644
--- a/doc/user/profile/preferences.md
+++ b/doc/user/profile/preferences.md
@@ -342,7 +342,7 @@ To access your **Followers** and **Following** tabs:
 
 ## Integrate your GitLab instance with third-party services
 
-Give third-party services access to your GitLab account.
+Give third-party services access to enhance the GitLab experience.
 
 ### Integrate your GitLab instance with Gitpod
 
@@ -370,6 +370,28 @@ To integrate with Sourcegraph:
 
 You must be the administrator of the GitLab instance to configure GitLab with Sourcegraph.
 
+### Integrate with the extension marketplace
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151352) in GitLab 17.0 [with flags](../../administration/feature_flags.md) named `web_ide_oauth` and `web_ide_extensions_marketplace`. Disabled by default.
+> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/459028) in GitLab 17.0.
+
+FLAG:
+The availability of this feature is controlled by a feature flag.
+For more information, see the history.
+
+You can use the [extension marketplace](../project/web_ide/index.md#extension-marketplace)
+to search and manage extensions for the Web IDE.
+For third-party extensions, you must enable the marketplace in your user preferences.
+
+To enable the extension marketplace for the Web IDE:
+
+1. On the left sidebar, select your avatar.
+1. Select **Preferences**.
+1. Go to the **Integrations** section.
+1. Select the **Enable extension marketplace** checkbox.
+1. In the third-party extension acknowledgement, select **I understand**.
+1. Select **Save changes**.
+
 <!-- ## Troubleshooting
 
 Include any troubleshooting steps that you can foresee. If you know beforehand what issues
diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md
index f286b3224b3e5e8f0cc6e29e62016844f8d5bc50..eb2f64332c09d3936183157118b2c3979f4c38a8 100644
--- a/doc/user/project/web_ide/index.md
+++ b/doc/user/project/web_ide/index.md
@@ -203,20 +203,6 @@ To view any notification you might have missed:
 1. On the bottom status bar, on the right, select the bell icon (**{notifications}**) for a list of notifications.
 1. Select the notification you want to view.
 
-<!-- ## Privacy and data collection for extensions
-
-The Web IDE Extension Marketplace is based on Open VSX. Open VSX does not collect any
-data about you or your activities on the platform.
-
-However, the privacy and data collection practices of extensions available on Open VSX can vary.
-Some extensions might collect data to provide personalized recommendations or to improve the functionality.
-Other extensions might collect data for analytics or advertising purposes.
-
-To protect your privacy and data:
-
-- Carefully review the permissions requested by an extension before you install the extension.
-- Keep your extensions up to date to ensure that any security or privacy vulnerabilities are addressed promptly. -->
-
 ## Interactive web terminals
 
 DETAILS:
@@ -235,6 +221,50 @@ However, you can use a terminal to install dependencies and compile and debug co
 
 For more information, see [Remote development](../remote_development/index.md).
 
+## Extension marketplace
+
+DETAILS:
+**Status**: Beta
+
+WARNING:
+This feature is in [Beta](../../../policy/experiment-beta-support.md#beta) and subject to change without notice.
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151352) in GitLab 17.0 [with flags](../../../administration/feature_flags.md) named `web_ide_oauth` and `web_ide_extensions_marketplace`. Disabled by default.
+> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/459028) in GitLab 17.0.
+
+FLAG:
+The availability of this feature is controlled by a feature flag.
+For more information, see the history.
+
+Prerequisites:
+
+- You must enable the extension marketplace in your [user preferences](../../profile/preferences.md#integrate-with-the-extension-marketplace).
+
+You can use the extension marketplace to download and run VS Code extensions in the Web IDE.
+
+The extension marketplace is preconfigured at the GitLab instance level
+and is hardcoded to [`https://open-vsx.org/`](https://open-vsx.org/).
+[Epic 11770](https://gitlab.com/groups/gitlab-org/-/epics/11770) proposes to change this behavior.
+
+### Install an extension
+
+To install an extension in the Web IDE:
+
+1. On the top menu bar, select **View > Extensions**,
+   or press <kbd>Command</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd>.
+1. In the search box, enter the extension name.
+1. Select the extension you want to install.
+1. Select **Install**.
+
+### Uninstall an extension
+
+To uninstall an extension in the Web IDE:
+
+1. On the top menu bar, select **View > Extensions**,
+   or press <kbd>Command</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd>.
+1. From the list of installed extensions, select the extension you want to uninstall.
+1. Select **Uninstall**.
+
 ## Related topics
 
 - [GitLab Duo Chat in the Web IDE](../../gitlab_duo_chat.md#use-gitlab-duo-chat-in-the-web-ide)
diff --git a/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb b/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb
index 4cb20daabbc6e1fe5e9f8f1e1bd9019232dae990..ca12819275c05acb7564b3d52e0e8ff8db785359 100644
--- a/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb
+++ b/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb
@@ -5,19 +5,6 @@ module Settings
     class ExtensionsGalleryMetadataGenerator
       include Messages
 
-      # NOTE: These `disabled_reason` enumeration values are also referenced/consumed in
-      #       the "gitlab-web-ide" and "gitlab-web-ide-vscode-fork" projects
-      #       (https://gitlab.com/gitlab-org/gitlab-web-ide & https://gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork),
-      #       so we must ensure that any changes made here are also reflected in those projects.
-      DISABLED_REASONS =
-        %i[
-          no_user
-          no_flag
-          instance_disabled
-          opt_in_unset
-          opt_in_disabled
-        ].to_h { |reason| [reason, reason] }.freeze
-
       # @param [Hash] value
       # @return [Hash]
       def self.generate(value)
@@ -29,7 +16,7 @@ def self.generate(value)
             extensions_marketplace_feature_flag_enabled
         }
 
-        extensions_gallery_metadata = generate_settings(
+        extensions_gallery_metadata = ::Gitlab::WebIde::ExtensionsMarketplace.metadata_for_user(
           user: user,
           flag_enabled: extensions_marketplace_feature_flag_enabled
         )
@@ -37,30 +24,6 @@ def self.generate(value)
         value[:settings][:vscode_extensions_gallery_metadata] = extensions_gallery_metadata
         value
       end
-
-      # @param [User, nil] user
-      # @param [Boolean, nil] flag_enabled
-      # @return [Hash]
-      def self.generate_settings(user:, flag_enabled:)
-        return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:no_user) } unless user
-        return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:no_flag) } if flag_enabled.nil?
-        return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:instance_disabled) } unless flag_enabled
-
-        # noinspection RubyNilAnalysis -- RubyMine doesn't realize user can't be nil because of guard clause above
-        opt_in_status = user.extensions_marketplace_opt_in_status.to_sym
-
-        return { enabled: true } if opt_in_status == :enabled
-        return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:opt_in_unset) } if opt_in_status == :unset
-
-        if opt_in_status == :disabled
-          return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:opt_in_disabled) }
-        end
-
-        # This is an internal bug due to an enumeration mismatch/inconsistency with the model, so lets throw an
-        # exception up the stack and let it be returned as a 500 - don't try to handle it via the ROP chain
-        raise "Invalid user.extensions_marketplace_opt_in_status: '#{opt_in_status}'. " \
-          "Supported statuses are: #{Enums::WebIde::ExtensionsMarketplaceOptInStatus.statuses.keys}." # rubocop:disable Layout/LineEndStringConcatenationIndentation -- This is already changed in the next version of gitlab-styles
-      end
     end
   end
 end
diff --git a/lib/gitlab/web_ide/extensions_marketplace.rb b/lib/gitlab/web_ide/extensions_marketplace.rb
new file mode 100644
index 0000000000000000000000000000000000000000..36a8db999fefc5dc18655bc3fcfa5400c9bee243
--- /dev/null
+++ b/lib/gitlab/web_ide/extensions_marketplace.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module WebIde
+    module ExtensionsMarketplace
+      # NOTE: These `disabled_reason` enumeration values are also referenced/consumed in
+      #       the "gitlab-web-ide" and "gitlab-web-ide-vscode-fork" projects
+      #       (https://gitlab.com/gitlab-org/gitlab-web-ide & https://gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork),
+      #       so we must ensure that any changes made here are also reflected in those projects.
+      DISABLED_REASONS =
+        %i[
+          no_user
+          no_flag
+          instance_disabled
+          opt_in_unset
+          opt_in_disabled
+        ].to_h { |reason| [reason, reason] }.freeze
+
+      class << self
+        def feature_enabled?(user:)
+          # TODO: Add instance-level setting for this https://gitlab.com/gitlab-org/gitlab/-/issues/451871
+
+          # note: OAuth **must** be enabled for us to use the extension marketplace
+          ::Gitlab::WebIde::DefaultOauthApplication.feature_enabled?(user) &&
+            Feature.enabled?(:web_ide_extensions_marketplace, user)
+        end
+
+        def vscode_settings
+          # TODO: Add instance-level setting for this https://gitlab.com/gitlab-org/gitlab/-/issues/451871
+          # TODO: We need to harmonize this with `ee/lib/remote_development/settings/defaults_initializer.rb`
+          #       https://gitlab.com/gitlab-org/gitlab/-/issues/460515
+          {
+            item_url: 'https://open-vsx.org/vscode/item',
+            service_url: 'https://open-vsx.org/vscode/gallery',
+            resource_url_template:
+              'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}',
+            control_url: '',
+            nls_base_url: '',
+            publisher_url: ''
+          }
+        end
+
+        # This value is used when the end-user is accepting the third-party extension marketplace integration.
+        def marketplace_home_url
+          "https://open-vsx.org"
+        end
+
+        def help_url
+          ::Gitlab::Routing.url_helpers.help_page_url('user/project/web_ide/index', anchor: 'extension-marketplace')
+        end
+
+        def help_preferences_url
+          ::Gitlab::Routing.url_helpers.help_page_url('user/profile/preferences',
+            anchor: 'integrate-with-the-extension-marketplace')
+        end
+
+        def user_preferences_url
+          ::Gitlab::Routing.url_helpers.profile_preferences_url(anchor: 'integrations')
+        end
+
+        # This returns a value to be used in the Web IDE config `extensionsGallerySettings`
+        # It should match the type expected by the Web IDE:
+        #
+        # - https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/51f9e91f890752596e7a3ef51f436fea07885eff/packages/web-ide-types/src/config.ts#L109
+        #
+        # @return [Hash]
+        def webide_extensions_gallery_settings(user:)
+          flag_enabled = feature_enabled?(user: user)
+          metadata = metadata_for_user(user: user, flag_enabled: flag_enabled)
+
+          return { enabled: true, vscode_settings: vscode_settings } if metadata.fetch(:enabled)
+
+          disabled_reason = metadata.fetch(:disabled_reason, nil)
+          result = { enabled: false, reason: disabled_reason, help_url: help_url }
+
+          if disabled_reason == :opt_in_unset || disabled_reason == :opt_in_disabled
+            result[:user_preferences_url] = user_preferences_url
+          end
+
+          result
+        end
+
+        # @param [User, nil] user
+        # @param [Boolean, nil] flag_enabled
+        # @return [Hash]
+        def metadata_for_user(user:, flag_enabled:)
+          return metadata_disabled(:no_user) unless user
+          return metadata_disabled(:no_flag) if flag_enabled.nil?
+          return metadata_disabled(:instance_disabled) unless flag_enabled
+
+          # noinspection RubyNilAnalysis -- RubyMine doesn't realize user can't be nil because of guard clause above
+          opt_in_status = user.extensions_marketplace_opt_in_status.to_sym
+
+          case opt_in_status
+          when :enabled
+            return metadata_enabled
+          when :unset
+            return metadata_disabled(:opt_in_unset)
+          when :disabled
+            return metadata_disabled(:opt_in_disabled)
+          end
+
+          # This is an internal bug due to an enumeration mismatch/inconsistency with the model
+          raise "Invalid user.extensions_marketplace_opt_in_status: '#{opt_in_status}'. " \
+            "Supported statuses are: #{Enums::WebIde::ExtensionsMarketplaceOptInStatus.statuses.keys}." # rubocop:disable Layout/LineEndStringConcatenationIndentation -- This is already changed in the next version of gitlab-styles
+        end
+
+        private
+
+        def metadata_enabled
+          { enabled: true }
+        end
+
+        def metadata_disabled(reason)
+          { enabled: false, disabled_reason: DISABLED_REASONS.fetch(reason) }
+        end
+      end
+    end
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index efa55e8281be2b0b6708f30c6e8fb3c1a39602e8..2a941af32a2a70d5ffcf630cfc0b64c26e35f04c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -38771,6 +38771,27 @@ msgstr ""
 msgid "Preferences saved."
 msgstr ""
 
+msgid "PreferencesIntegrations|By using the Extension Marketplace, you will send data, such as IP address and other device information, to %{url} in accordance with their independent terms and privacy policy."
+msgstr ""
+
+msgid "PreferencesIntegrations|GitLab does not assume any responsibility for the functionality of these third-party extensions. Each %{boldStart}third-party%{boldEnd} extension has %{boldStart}their own independent%{boldEnd} terms and conditions listed in the marketplace. By installing an extension, you are agreeing to the terms & conditions and Privacy Policy that govern each individual extension as listed in the marketplace."
+msgstr ""
+
+msgid "PreferencesIntegrations|I understand"
+msgstr ""
+
+msgid "PreferencesIntegrations|Learn more"
+msgstr ""
+
+msgid "PreferencesIntegrations|Third-Party Extensions Acknowledgement"
+msgstr ""
+
+msgid "PreferencesIntegrations|Third-party extensions are now available in the Web IDE. While each extension runs in a secure browser sandbox, %{boldStart}third-party extensions%{boldEnd} may have access to the contents of the files opened in the Web IDE, %{boldStart}including any personal data in those files%{boldEnd}, and may communicate with external servers."
+msgstr ""
+
+msgid "PreferencesIntegrations|Uses %{extensions_marketplace_home} as the extension marketplace for the Web IDE."
+msgstr ""
+
 msgid "Preferences|%{link_start}List of keyboard shortcuts%{link_end}"
 msgstr ""
 
@@ -38837,6 +38858,9 @@ msgstr ""
 msgid "Preferences|Enable Zoekt code search"
 msgstr ""
 
+msgid "Preferences|Enable extension marketplace"
+msgstr ""
+
 msgid "Preferences|Enable follow users"
 msgstr ""
 
@@ -38927,6 +38951,9 @@ msgstr ""
 msgid "Preferences|Use relative times"
 msgstr ""
 
+msgid "Preferences|Web IDE"
+msgstr ""
+
 msgid "Preferences|When you type in a description or comment box, pressing %{kbdOpen}Enter%{kbdClose} in a list adds a new item below."
 msgstr ""
 
diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb
index 7a026da540125c2ae20063d0fa0af292280978e1..504c75aaf532a20d0a599604421e24ea794d3403 100644
--- a/spec/controllers/profiles/preferences_controller_spec.rb
+++ b/spec/controllers/profiles/preferences_controller_spec.rb
@@ -61,7 +61,8 @@ def go(params: {}, format: :json)
           tab_width: '5',
           project_shortcut_buttons: 'true',
           keyboard_shortcuts_enabled: 'true',
-          render_whitespace_in_code: 'true'
+          render_whitespace_in_code: 'true',
+          extensions_marketplace_enabled: '1'
         }.with_indifferent_access
 
         expect(user).to receive(:assign_attributes).with(ActionController::Parameters.new(prefs).permit!)
diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js
index a757b8ec622838978cc9ec2981d2a76ab10e4e06..3a75e110c73a3ac80b12efbab1667d6f138bd5fb 100644
--- a/spec/frontend/ide/init_gitlab_web_ide_spec.js
+++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js
@@ -36,6 +36,13 @@ const TEST_START_REMOTE_PARAMS = {
   remotePath: '/test/projects/f oo',
   connectionToken: '123abc',
 };
+const TEST_EXTENSIONS_GALLERY_SETTINGS = JSON.stringify({
+  enabled: true,
+  vscode_settings: {
+    item_url: 'https://gitlab.test/vscode/marketplace/item/url',
+    service_url: 'https://gitlab.test/vscode/marketplace/service/url',
+  },
+});
 const TEST_EDITOR_FONT_SRC_URL = 'http://gitlab.test/assets/gitlab-mono/GitLabMono.woff2';
 const TEST_EDITOR_FONT_FORMAT = 'woff2';
 const TEST_EDITOR_FONT_FAMILY = 'GitLab Mono';
@@ -262,4 +269,28 @@ describe('ide/init_gitlab_web_ide', () => {
       );
     });
   });
+
+  describe('when extensionsGallerySettings is in dataset', () => {
+    beforeEach(() => {
+      findRootElement().dataset.extensionsGallerySettings = TEST_EXTENSIONS_GALLERY_SETTINGS;
+
+      createSubject();
+    });
+
+    it('calls start with element and extensionsGallerySettings', () => {
+      expect(start).toHaveBeenCalledTimes(1);
+      expect(start).toHaveBeenCalledWith(
+        findRootElement(),
+        expect.objectContaining({
+          extensionsGallerySettings: {
+            enabled: true,
+            vscodeSettings: {
+              itemUrl: 'https://gitlab.test/vscode/marketplace/item/url',
+              serviceUrl: 'https://gitlab.test/vscode/marketplace/service/url',
+            },
+          },
+        }),
+      );
+    });
+  });
 });
diff --git a/spec/frontend/profile/preferences/components/extensions_marketplace_warning_spec.js b/spec/frontend/profile/preferences/components/extensions_marketplace_warning_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c037b406633cafea99335ac643aa03a72e5035c3
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/extensions_marketplace_warning_spec.js
@@ -0,0 +1,154 @@
+import { nextTick } from 'vue';
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ExtensionsMarketplaceWarning, {
+  WARNING_PARAGRAPH_1,
+  WARNING_PARAGRAPH_2,
+  WARNING_PARAGRAPH_3,
+} from '~/profile/preferences/components/extensions_marketplace_warning.vue';
+
+const TEST_HELP_URL = 'http://localhost/help/url';
+const TEST_MARKETPLACE_URL = 'http://localhost/extensions/marketplace';
+
+describe('profile/preferences/components/extensions_marketplace_warning', () => {
+  let wrapper;
+
+  const createComponent = (props = {}) => {
+    wrapper = shallowMount(ExtensionsMarketplaceWarning, {
+      propsData: {
+        value: false,
+        helpUrl: TEST_HELP_URL,
+        ...props,
+      },
+      provide: {
+        extensionsMarketplaceUrl: TEST_MARKETPLACE_URL,
+      },
+      stubs: {
+        GlSprintf,
+      },
+    });
+  };
+
+  const findModal = () => wrapper.findComponent(GlModal);
+
+  const closeModal = async () => {
+    findModal().vm.$emit('change', false);
+    findModal().vm.$emit('hide');
+
+    await nextTick();
+  };
+
+  const setValue = async (value) => {
+    wrapper.setProps({ value });
+    await nextTick();
+  };
+
+  describe('when initializes with value: false', () => {
+    beforeEach(() => {
+      createComponent({ value: false });
+    });
+
+    it('does not show modal', () => {
+      expect(findModal().props('visible')).toBe(false);
+    });
+
+    describe('when value changes to true', () => {
+      beforeEach(async () => {
+        await setValue(true);
+      });
+
+      it('shows modal with props', () => {
+        expect(findModal().props()).toMatchObject({
+          visible: true,
+          modalId: 'extensions-marketplace-warning-modal',
+          title: 'Third-Party Extensions Acknowledgement',
+          actionPrimary: {
+            text: 'I understand',
+          },
+          actionSecondary: {
+            text: 'Learn more',
+            attributes: {
+              href: TEST_HELP_URL,
+              variant: 'default',
+            },
+          },
+        });
+      });
+
+      it('shows modal text', () => {
+        expect(findModal().text()).toMatchInterpolatedText(
+          `${WARNING_PARAGRAPH_1} ${WARNING_PARAGRAPH_2} ${WARNING_PARAGRAPH_3}`.replace(
+            '%{url}',
+            TEST_MARKETPLACE_URL,
+          ),
+        );
+      });
+
+      it('emits input to reset value to false', () => {
+        expect(wrapper.emitted('input')).toEqual([[false]]);
+      });
+
+      describe('when modal canceled', () => {
+        beforeEach(async () => {
+          await closeModal();
+        });
+
+        it('does not change anything', () => {
+          expect(wrapper.emitted('input')).toEqual([[false]]);
+        });
+
+        it('opens modal again when value changes', async () => {
+          await setValue(false);
+
+          expect(findModal().props('visible')).toBe(false);
+
+          await setValue(true);
+
+          expect(findModal().props('visible')).toBe(true);
+        });
+      });
+
+      describe('when modal is accepted', () => {
+        beforeEach(async () => {
+          findModal().vm.$emit('primary');
+
+          await closeModal();
+        });
+
+        it('updates value', () => {
+          expect(wrapper.emitted('input')).toEqual([[false], [true]]);
+        });
+
+        it('does not open modal when value changes', async () => {
+          await setValue(false);
+
+          expect(findModal().props('visible')).toBe(false);
+
+          await setValue(true);
+
+          expect(findModal().props('visible')).toBe(false);
+        });
+      });
+    });
+  });
+
+  describe('when initiailized with value: true', () => {
+    beforeEach(() => {
+      createComponent({ value: true });
+    });
+
+    it('does not show modal', () => {
+      expect(findModal().props('visible')).toBe(false);
+    });
+
+    it('does not open modal when value changes', async () => {
+      await setValue(false);
+
+      expect(findModal().props('visible')).toBe(false);
+
+      await setValue(true);
+
+      expect(findModal().props('visible')).toBe(false);
+    });
+  });
+});
diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js
index b809f2f4aede8dc7cd505f827979074d3128424f..0ea6271c944cb317cb7b6c0a4c09acf6ce6c8d36 100644
--- a/spec/frontend/profile/preferences/components/integration_view_spec.js
+++ b/spec/frontend/profile/preferences/components/integration_view_spec.js
@@ -1,10 +1,11 @@
+import { nextTick } from 'vue';
 import { GlFormGroup } from '@gitlab/ui';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 
 import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
 import IntegrationView from '~/profile/preferences/components/integration_view.vue';
 import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
-import { integrationViews, userFields } from '../mock_data';
+import { integrationViews } from '../mock_data';
 
 const viewProps = convertObjectPropsToCamelCase(integrationViews[0]);
 
@@ -16,16 +17,12 @@ describe('IntegrationView component', () => {
       label: 'Enable foo',
       formName: 'foo_enabled',
     },
+    value: true,
     ...viewProps,
   };
 
-  function createComponent(options = {}) {
-    const { props = {}, provide = {} } = options;
+  function createComponent(props = {}) {
     return mountExtended(IntegrationView, {
-      provide: {
-        userFields,
-        ...provide,
-      },
       propsData: {
         ...defaultProps,
         ...props,
@@ -73,19 +70,7 @@ describe('IntegrationView component', () => {
   });
 
   it('should set the checkbox value to be false when false is provided', () => {
-    wrapper = createComponent({
-      provide: {
-        userFields: {
-          foo_enabled: false,
-        },
-      },
-    });
-
-    expect(findCheckbox().element.checked).toBe(false);
-  });
-
-  it('should set the checkbox value to be false when not provided', () => {
-    wrapper = createComponent({ provide: { userFields: {} } });
+    wrapper = createComponent({ value: false });
 
     expect(findCheckbox().element.checked).toBe(false);
   });
@@ -95,4 +80,28 @@ describe('IntegrationView component', () => {
 
     expect(wrapper.findComponent(IntegrationHelpText).exists()).toBe(true);
   });
+
+  describe('when prop value changes', () => {
+    beforeEach(async () => {
+      wrapper = createComponent();
+
+      wrapper.setProps({ value: false });
+      await nextTick();
+    });
+
+    it('should update the checkbox value', () => {
+      expect(findCheckbox().element.checked).toBe(false);
+    });
+  });
+
+  it('when checkbox clicked, should update the checkbox value', async () => {
+    wrapper = createComponent({ value: false });
+
+    expect(wrapper.emitted('input')).toBe(undefined);
+
+    findCheckbox().setChecked(true);
+    await nextTick();
+
+    expect(wrapper.emitted('input')).toEqual([[true]]);
+  });
 });
diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
index 6fa7f4303f4469bb00290e6231e76205590ebf3f..67f766ad1161c339f0a519abb24476f22ce7506c 100644
--- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js
+++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
@@ -6,7 +6,12 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
 import { createAlert, VARIANT_DANGER } from '~/alert';
 import IntegrationView from '~/profile/preferences/components/integration_view.vue';
 import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
-import { i18n } from '~/profile/preferences/constants';
+import ExtensionsMarketplaceWarning from '~/profile/preferences/components/extensions_marketplace_warning.vue';
+import {
+  i18n,
+  INTEGRATION_EXTENSIONS_MARKETPLACE,
+  INTEGRATION_VIEW_CONFIGS,
+} from '~/profile/preferences/constants';
 import {
   integrationViews,
   userFields,
@@ -243,4 +248,47 @@ describe('ProfilePreferences component', () => {
       expect(window.location.reload).toHaveBeenCalledTimes(1);
     });
   });
+
+  describe('with extensions marketplace integration view', () => {
+    beforeEach(() => {
+      wrapper = createComponent({
+        provide: {
+          integrationViews: [
+            {
+              name: INTEGRATION_EXTENSIONS_MARKETPLACE,
+              help_link: 'http://foo.com/help-extensions-marketplace',
+              message: 'Click %{linkStart}Foo%{linkEnd}!',
+              message_url: 'http://foo.com',
+            },
+          ],
+        },
+      });
+    });
+
+    it('renders view with 2-way-bound value', async () => {
+      const integrationView = wrapper.findComponent(IntegrationView);
+
+      expect(integrationView.props()).toMatchObject({
+        value: false,
+        config: INTEGRATION_VIEW_CONFIGS[INTEGRATION_EXTENSIONS_MARKETPLACE],
+      });
+
+      await integrationView.vm.$emit('input', true);
+
+      expect(integrationView.props('value')).toBe(true);
+    });
+
+    it('renders extensions marketplace warning with 2-way-bound value', async () => {
+      const warning = wrapper.findComponent(ExtensionsMarketplaceWarning);
+
+      expect(warning.props()).toEqual({
+        helpUrl: 'http://foo.com/help-extensions-marketplace',
+        value: false,
+      });
+
+      await warning.vm.$emit('input', true);
+
+      expect(warning.props('value')).toBe(true);
+    });
+  });
 });
diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb
index 99ef0998fda82b5f8190deefde957d7d86ea4695..4af32085d66901cbc918a0f3e989ac74787c5e31 100644
--- a/spec/helpers/ide_helper_spec.rb
+++ b/spec/helpers/ide_helper_spec.rb
@@ -83,6 +83,15 @@
           .to include(base_data)
       end
 
+      it 'includes extensions gallery settings' do
+        expect(Gitlab::WebIde::ExtensionsMarketplace).to receive(:webide_extensions_gallery_settings)
+          .with(user: user).and_return({ enabled: false })
+
+        actual = helper.ide_data(project: nil, fork_info: fork_info, params: params)
+
+        expect(actual).to include({ 'extensions-gallery-settings' => { enabled: false }.to_json })
+      end
+
       it 'includes editor font configuration' do
         ide_data = helper.ide_data(project: nil, fork_info: fork_info, params: params)
         editor_font = ::Gitlab::Json.parse(ide_data.fetch('editor-font'), symbolize_names: true)
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index b259cb8eaf7159e5dd1047de66bee5de9d5a55b6..5306b86d08f2e2ad77234eb2a891dadd372c968b 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -3,11 +3,14 @@
 require 'spec_helper'
 
 RSpec.describe PreferencesHelper do
-  describe '#dashboard_choices' do
-    let(:user) { build(:user) }
+  let_it_be(:user) { build(:user) }
+
+  before do
+    allow(helper).to receive(:current_user).and_return(user)
+  end
 
+  describe '#dashboard_choices' do
     before do
-      allow(helper).to receive(:current_user).and_return(user)
       allow(helper).to receive(:can?).and_return(false)
     end
 
@@ -242,16 +245,15 @@ def stub_user(messages = {})
 
   describe '#integration_views' do
     let(:gitpod_url) { 'http://gitpod.test' }
+    let(:gitpod_enabled) { false }
 
     before do
       allow(Gitlab::CurrentSettings).to receive(:gitpod_enabled).and_return(gitpod_enabled)
       allow(Gitlab::CurrentSettings).to receive(:gitpod_url).and_return(gitpod_url)
     end
 
-    context 'when Gitpod is not enabled' do
-      let(:gitpod_enabled) { false }
-
-      it 'does not include Gitpod integration' do
+    context 'on default' do
+      it 'does not include integration views' do
         expect(helper.integration_views).to be_empty
       end
     end
@@ -260,20 +262,36 @@ def stub_user(messages = {})
       let(:gitpod_enabled) { true }
 
       it 'includes Gitpod integration' do
-        expect(helper.integration_views[0][:name]).to eq 'gitpod'
-      end
-
-      it 'returns the Gitpod url configured in settings' do
-        expect(helper.integration_views[0][:message_url]).to eq gitpod_url
+        expect(helper.integration_views).to include(
+          a_hash_including({ name: 'gitpod', message_url: gitpod_url })
+        )
       end
 
       context 'when Gitpod url is not set' do
         let(:gitpod_url) { '' }
 
-        it 'returns the Gitpod default url' do
-          expect(helper.integration_views[0][:message_url]).to eq 'https://gitpod.io/'
+        it 'includes Gitpod integration with default url' do
+          expect(helper.integration_views).to include(
+            a_hash_including({ name: 'gitpod', message_url: 'https://gitpod.io/' })
+          )
         end
       end
     end
+
+    context 'when WebIdeExtensionsMarketplace is enabled' do
+      before do
+        allow(Gitlab::WebIde::ExtensionsMarketplace).to receive(:feature_enabled?).with(user: user).and_return(true)
+      end
+
+      it 'includes extension marketplace integration' do
+        expect(helper.integration_views).to include(
+          a_hash_including({
+            name: 'extensions_marketplace',
+            message: 'Uses %{linkStart}https://open-vsx.org%{linkEnd} as the extension marketplace for the Web IDE.',
+            message_url: 'https://open-vsx.org'
+          })
+        )
+      end
+    end
   end
 end
diff --git a/spec/lib/gitlab/web_ide/extensions_marketplace_spec.rb b/spec/lib/gitlab/web_ide/extensions_marketplace_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7c9ba0553a60330ad405b608b90cac1d1c1d599c
--- /dev/null
+++ b/spec/lib/gitlab/web_ide/extensions_marketplace_spec.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WebIde::ExtensionsMarketplace, feature_category: :web_ide do
+  using RSpec::Parameterized::TableSyntax
+
+  let_it_be_with_reload(:current_user) { create(:user) }
+  let_it_be(:default_vscode_settings) do
+    {
+      item_url: 'https://open-vsx.org/vscode/item',
+      service_url: 'https://open-vsx.org/vscode/gallery',
+      resource_url_template:
+        'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}'
+    }
+  end
+
+  describe '#feature_enabled?' do
+    where(:web_ide_extensions_marketplace, :web_ide_oauth, :expectation) do
+      ref(:current_user) | false | false
+      false              | true  | false
+      ref(:current_user) | true  | true
+    end
+
+    with_them do
+      it 'returns the expected value' do
+        stub_feature_flags(web_ide_extensions_marketplace: web_ide_extensions_marketplace)
+        expect(::Gitlab::WebIde::DefaultOauthApplication).to receive(:feature_enabled?)
+          .with(current_user).and_return(web_ide_oauth)
+
+        expect(described_class.feature_enabled?(user: current_user)).to be(expectation)
+      end
+    end
+  end
+
+  describe '#vscode_settings' do
+    it { expect(described_class.vscode_settings).to match(hash_including(default_vscode_settings)) }
+  end
+
+  describe '#marketplace_home_url' do
+    it { expect(described_class.marketplace_home_url).to eq('https://open-vsx.org') }
+  end
+
+  describe '#help_url' do
+    it { expect(described_class.help_url).to match('/help/user/project/web_ide/index#extension-marketplace') }
+  end
+
+  describe '#help_preferences_url' do
+    it do
+      expect(described_class.help_preferences_url).to match(
+        '/help/user/profile/preferences#integrate-with-the-extension-marketplace'
+      )
+    end
+  end
+
+  describe '#user_preferences_url' do
+    it { expect(described_class.user_preferences_url).to match('/-/profile/preferences#integrations') }
+  end
+
+  describe '#metadata_for_user' do
+    where(:user, :opt_in_status, :flag_enabled, :expectation) do
+      nil                | nil       | false | { enabled: false, disabled_reason: :no_user }
+      ref(:current_user) | nil       | nil   | { enabled: false, disabled_reason: :no_flag }
+      ref(:current_user) | nil       | false | { enabled: false, disabled_reason: :instance_disabled }
+      ref(:current_user) | :enabled  | true  | { enabled: true }
+      ref(:current_user) | :disabled | true  | { enabled: false, disabled_reason: :opt_in_disabled }
+      ref(:current_user) | :unset    | true  | { enabled: false, disabled_reason: :opt_in_unset }
+    end
+
+    with_them do
+      subject(:metadata) { described_class.metadata_for_user(user: user, flag_enabled: flag_enabled) }
+
+      before do
+        user.update!(extensions_marketplace_opt_in_status: opt_in_status) if user && opt_in_status
+      end
+
+      it 'returns expected metadata for user' do
+        expect(metadata).to eq(expectation)
+      end
+    end
+  end
+
+  describe '#webide_extensions_gallery_settings' do
+    subject(:webide_settings) { described_class.webide_extensions_gallery_settings(user: current_user) }
+
+    context 'when instance enabled' do
+      before do
+        stub_feature_flags(
+          web_ide_extensions_marketplace: current_user,
+          web_ide_oauth: current_user,
+          vscode_web_ide: current_user
+        )
+      end
+
+      it 'when user opt in enabled, returns enabled settings' do
+        current_user.update!(extensions_marketplace_opt_in_status: :enabled)
+
+        expect(webide_settings).to match({
+          enabled: true,
+          vscode_settings: hash_including(default_vscode_settings)
+        })
+      end
+
+      context 'when user opt in disabled' do
+        where(:opt_in_status, :reason) do
+          :unset    | :opt_in_unset
+          :disabled | :opt_in_disabled
+        end
+
+        with_them do
+          it 'returns disabled settings' do
+            current_user.update!(extensions_marketplace_opt_in_status: opt_in_status)
+
+            expect(webide_settings).to match({
+              enabled: false,
+              reason: reason,
+              help_url: described_class.help_url,
+              user_preferences_url: described_class.user_preferences_url
+            })
+          end
+        end
+      end
+    end
+
+    context 'when instance disabled' do
+      it 'returns disabled settings and help url' do
+        expect(webide_settings).to match({
+          enabled: false,
+          reason: :instance_disabled,
+          help_url: described_class.help_url
+        })
+      end
+    end
+  end
+end
diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb
index 1c520f08b7cbd46c124d7ca28d3858d75945a462..d90958aa95acc8bc226933a2c1ed4925fe6311ab 100644
--- a/spec/models/user_preference_spec.rb
+++ b/spec/models/user_preference_spec.rb
@@ -340,4 +340,43 @@
       it { expect(user_preference.early_access_event_tracking?).to be false }
     end
   end
+
+  describe '#extensions_marketplace_enabled' do
+    where(:opt_in_status, :expected_value) do
+      [
+        ["enabled", true],
+        ["disabled", false],
+        ["unset", false]
+      ]
+    end
+
+    with_them do
+      it 'returns boolean from extensions_marketplace_opt_in_status' do
+        user_preference.update!(extensions_marketplace_opt_in_status: opt_in_status)
+
+        expect(user_preference.extensions_marketplace_enabled).to be expected_value
+      end
+    end
+  end
+
+  describe '#extensions_marketplace_enabled=' do
+    where(:value, :expected_opt_in_status) do
+      [
+        [true, "enabled"],
+        [false, "disabled"],
+        [0, "disabled"],
+        [1, "enabled"]
+      ]
+    end
+
+    with_them do
+      it 'updates extensions_marketplace_opt_in_status' do
+        user_preference.update!(extensions_marketplace_opt_in_status: 'unset')
+
+        user_preference.extensions_marketplace_enabled = value
+
+        expect(user_preference.extensions_marketplace_opt_in_status).to be expected_opt_in_status
+      end
+    end
+  end
 end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index e4f8be682bc1832516963517a4236a7bfcaf61c2..0f85f74deea5944413e19afc8e7d1d01732da0ed 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -89,6 +89,9 @@
     it { is_expected.to delegate_method(:use_new_navigation).to(:user_preference) }
     it { is_expected.to delegate_method(:use_new_navigation=).to(:user_preference).with_arguments(:args) }
 
+    it { is_expected.to delegate_method(:extensions_marketplace_enabled).to(:user_preference) }
+    it { is_expected.to delegate_method(:extensions_marketplace_enabled=).to(:user_preference).with_arguments(:args) }
+
     it { is_expected.to delegate_method(:pinned_nav_items).to(:user_preference) }
     it { is_expected.to delegate_method(:pinned_nav_items=).to(:user_preference).with_arguments(:args) }