diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js
index 52ed67b8c7bc489406379aaaca06e431f6875e8e..ebdcd1e074d7f906acef34495b1e86b2c6f9f516 100644
--- a/app/assets/javascripts/authentication/mount_2fa.js
+++ b/app/assets/javascripts/authentication/mount_2fa.js
@@ -1,12 +1,12 @@
 import $ from 'jquery';
 import initU2F from './u2f';
 import U2FRegister from './u2f/register';
-import initWebauthn from './webauthn';
+import initWebauthnAuthentication from './webauthn';
 import WebAuthnRegister from './webauthn/register';
 
 export const mount2faAuthentication = () => {
   if (gon.webauthn) {
-    initWebauthn();
+    initWebauthnAuthentication();
   } else {
     initU2F();
   }
diff --git a/app/assets/javascripts/authentication/webauthn/components/registration.vue b/app/assets/javascripts/authentication/webauthn/components/registration.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1cc57046562f0ecdc0c3836b702c347901eb3d07
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/components/registration.vue
@@ -0,0 +1,226 @@
+<script>
+import {
+  GlAlert,
+  GlButton,
+  GlForm,
+  GlFormInput,
+  GlFormGroup,
+  GlLink,
+  GlLoadingIcon,
+  GlSprintf,
+} from '@gitlab/ui';
+import {
+  I18N_BUTTON_REGISTER,
+  I18N_BUTTON_SETUP,
+  I18N_BUTTON_TRY_AGAIN,
+  I18N_DEVICE_NAME,
+  I18N_DEVICE_NAME_DESCRIPTION,
+  I18N_DEVICE_NAME_PLACEHOLDER,
+  I18N_ERROR_HTTP,
+  I18N_ERROR_UNSUPPORTED_BROWSER,
+  I18N_INFO_TEXT,
+  I18N_NOTICE,
+  I18N_PASSWORD,
+  I18N_PASSWORD_DESCRIPTION,
+  I18N_STATUS_SUCCESS,
+  I18N_STATUS_WAITING,
+  STATE_ERROR,
+  STATE_READY,
+  STATE_SUCCESS,
+  STATE_UNSUPPORTED,
+  STATE_WAITING,
+  WEBAUTHN_DOCUMENTATION_PATH,
+} from '~/authentication/webauthn/constants';
+import WebAuthnError from '~/authentication/webauthn/error';
+import {
+  FLOW_REGISTER,
+  convertCreateParams,
+  convertCreateResponse,
+  isHTTPS,
+  supported,
+} from '~/authentication/webauthn/util';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+  name: 'WebAuthnRegistration',
+  components: {
+    GlAlert,
+    GlButton,
+    GlForm,
+    GlFormInput,
+    GlFormGroup,
+    GlLink,
+    GlLoadingIcon,
+    GlSprintf,
+  },
+  I18N_BUTTON_REGISTER,
+  I18N_BUTTON_SETUP,
+  I18N_BUTTON_TRY_AGAIN,
+  I18N_DEVICE_NAME,
+  I18N_DEVICE_NAME_DESCRIPTION,
+  I18N_DEVICE_NAME_PLACEHOLDER,
+  I18N_ERROR_HTTP,
+  I18N_ERROR_UNSUPPORTED_BROWSER,
+  I18N_INFO_TEXT,
+  I18N_NOTICE,
+  I18N_PASSWORD,
+  I18N_PASSWORD_DESCRIPTION,
+  I18N_STATUS_SUCCESS,
+  I18N_STATUS_WAITING,
+  STATE_ERROR,
+  STATE_READY,
+  STATE_SUCCESS,
+  STATE_UNSUPPORTED,
+  STATE_WAITING,
+  WEBAUTHN_DOCUMENTATION_PATH,
+  inject: ['initialError', 'passwordRequired', 'targetPath'],
+  data() {
+    return {
+      csrfToken: csrf.token,
+      form: { deviceName: '', password: '' },
+      state: STATE_UNSUPPORTED,
+      errorMessage: this.initialError,
+      credentials: null,
+    };
+  },
+  computed: {
+    disabled() {
+      const isEmptyDeviceName = this.form.deviceName.trim() === '';
+      const isEmptyPassword = this.form.password.trim() === '';
+
+      if (this.passwordRequired === false) {
+        return isEmptyDeviceName;
+      }
+
+      return isEmptyDeviceName || isEmptyPassword;
+    },
+  },
+  created() {
+    if (this.errorMessage) {
+      this.state = STATE_ERROR;
+      return;
+    }
+
+    if (isHTTPS() && supported()) {
+      this.state = STATE_READY;
+      return;
+    }
+
+    this.errorMessage = isHTTPS() ? I18N_ERROR_UNSUPPORTED_BROWSER : I18N_ERROR_HTTP;
+  },
+  methods: {
+    isCurrentState(state) {
+      return this.state === state;
+    },
+    async onRegister() {
+      this.state = STATE_WAITING;
+
+      try {
+        const credentials = await navigator.credentials.create({
+          publicKey: convertCreateParams(gon.webauthn.options),
+        });
+
+        this.credentials = JSON.stringify(convertCreateResponse(credentials));
+        this.state = STATE_SUCCESS;
+      } catch (error) {
+        this.errorMessage = new WebAuthnError(error, FLOW_REGISTER).message();
+        this.state = STATE_ERROR;
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <div>
+    <template v-if="isCurrentState($options.STATE_UNSUPPORTED)">
+      <gl-alert variant="danger" :dismissible="false">{{ errorMessage }}</gl-alert>
+    </template>
+
+    <template v-else-if="isCurrentState($options.STATE_READY)">
+      <div class="row">
+        <div class="col-md-5">
+          <gl-button variant="confirm" @click="onRegister">{{
+            $options.I18N_BUTTON_SETUP
+          }}</gl-button>
+        </div>
+        <div class="col-md-7">
+          <p>{{ $options.I18N_INFO_TEXT }}</p>
+        </div>
+      </div>
+    </template>
+
+    <template v-else-if="isCurrentState($options.STATE_WAITING)">
+      <gl-alert :dismissible="false">
+        {{ $options.I18N_STATUS_WAITING }}
+        <gl-loading-icon />
+      </gl-alert>
+    </template>
+
+    <template v-else-if="isCurrentState($options.STATE_SUCCESS)">
+      <p>{{ $options.I18N_STATUS_SUCCESS }}</p>
+      <gl-alert :dismissible="false" class="gl-mb-5">
+        <gl-sprintf :message="$options.I18N_NOTICE">
+          <template #link="{ content }">
+            <gl-link :href="$options.WEBAUTHN_DOCUMENTATION_PATH" target="_blank">{{
+              content
+            }}</gl-link>
+          </template>
+        </gl-sprintf>
+      </gl-alert>
+
+      <div class="row">
+        <gl-form method="post" :action="targetPath" class="col-md-9" data-testid="create-webauthn">
+          <gl-form-group
+            v-if="passwordRequired"
+            :description="$options.I18N_PASSWORD_DESCRIPTION"
+            :label="$options.I18N_PASSWORD"
+            label-for="webauthn-registration-current-password"
+          >
+            <gl-form-input
+              id="webauthn-registration-current-password"
+              v-model="form.password"
+              name="current_password"
+              type="password"
+              autocomplete="current-password"
+              data-testid="current-password-input"
+            />
+          </gl-form-group>
+
+          <gl-form-group
+            :description="$options.I18N_DEVICE_NAME_DESCRIPTION"
+            :label="$options.I18N_DEVICE_NAME"
+            label-for="device-name"
+          >
+            <gl-form-input
+              id="device-name"
+              v-model="form.deviceName"
+              name="device_registration[name]"
+              :placeholder="$options.I18N_DEVICE_NAME_PLACEHOLDER"
+              data-testid="device-name-input"
+            />
+          </gl-form-group>
+
+          <input type="hidden" name="device_registration[device_response]" :value="credentials" />
+          <input :value="csrfToken" type="hidden" name="authenticity_token" />
+
+          <gl-button type="submit" :disabled="disabled" variant="confirm">{{
+            $options.I18N_BUTTON_REGISTER
+          }}</gl-button>
+        </gl-form>
+      </div>
+    </template>
+
+    <template v-else-if="isCurrentState($options.STATE_ERROR)">
+      <gl-alert
+        variant="danger"
+        :dismissible="false"
+        class="gl-mb-5"
+        :secondary-button-text="$options.I18N_BUTTON_TRY_AGAIN"
+        @secondaryAction="onRegister"
+      >
+        {{ errorMessage }}
+      </gl-alert>
+    </template>
+  </div>
+</template>
diff --git a/app/assets/javascripts/authentication/webauthn/constants.js b/app/assets/javascripts/authentication/webauthn/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..6646cb2eb3fdb32931bf13429f9c7daac74b122e
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/constants.js
@@ -0,0 +1,44 @@
+import { __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const I18N_BUTTON_REGISTER = __('Register device');
+export const I18N_BUTTON_SETUP = __('Set up new device');
+export const I18N_BUTTON_TRY_AGAIN = __('Try again?');
+export const I18N_DEVICE_NAME = __('Device name');
+export const I18N_DEVICE_NAME_DESCRIPTION = __(
+  'Excluding USB security keys, you should include the browser name together with the device name.',
+);
+export const I18N_DEVICE_NAME_PLACEHOLDER = __('Macbook Touch ID on Edge');
+export const I18N_ERROR_HTTP = __(
+  'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.',
+);
+export const I18N_ERROR_UNSUPPORTED_BROWSER = __(
+  "Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+).",
+);
+export const I18N_INFO_TEXT = __(
+  'Your device needs to be set up. Plug it in (if needed) and click the button on the left.',
+);
+export const I18N_NOTICE = __(
+  'You must save your recovery codes after you first register a two-factor authenticator, so you do not lose access to your account. %{linkStart}See the documentation on managing your WebAuthn device for more information.%{linkEnd}',
+);
+export const I18N_PASSWORD = __('Current password');
+export const I18N_PASSWORD_DESCRIPTION = __(
+  'Your current password is required to register a new device.',
+);
+export const I18N_STATUS_SUCCESS = __(
+  'Your device was successfully set up! Give it a name and register it with the GitLab server.',
+);
+export const I18N_STATUS_WAITING = __(
+  'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
+);
+
+export const STATE_ERROR = 'error';
+export const STATE_READY = 'ready';
+export const STATE_SUCCESS = 'success';
+export const STATE_UNSUPPORTED = 'unsupported';
+export const STATE_WAITING = 'waiting';
+
+export const WEBAUTHN_DOCUMENTATION_PATH = helpPagePath(
+  'user/profile/account/two_factor_authentication',
+  { anchor: 'set-up-a-webauthn-device' },
+);
diff --git a/app/assets/javascripts/authentication/webauthn/index.js b/app/assets/javascripts/authentication/webauthn/index.js
index bbf694c769835d799113fba84b205016a8c86c25..e9c20ce7795374f660bda09f7280f2221a98c990 100644
--- a/app/assets/javascripts/authentication/webauthn/index.js
+++ b/app/assets/javascripts/authentication/webauthn/index.js
@@ -1,5 +1,5 @@
 import $ from 'jquery';
-import WebAuthnAuthenticate from './authenticate';
+import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate';
 
 export default () => {
   const webauthnAuthenticate = new WebAuthnAuthenticate(
diff --git a/app/assets/javascripts/authentication/webauthn/registration.js b/app/assets/javascripts/authentication/webauthn/registration.js
new file mode 100644
index 0000000000000000000000000000000000000000..67906a24857f2b8db63bac7990f3cea73ea1d784
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/registration.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import WebAuthnRegistration from '~/authentication/webauthn/components/registration.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export const initWebAuthnRegistration = () => {
+  const el = document.querySelector('#js-device-registration');
+
+  if (!el) {
+    return null;
+  }
+
+  const { initialError, passwordRequired, targetPath } = el.dataset;
+
+  return new Vue({
+    el,
+    name: 'WebAuthnRegistrationRoot',
+    provide: { initialError, passwordRequired: parseBoolean(passwordRequired), targetPath },
+    render(h) {
+      return h(WebAuthnRegistration);
+    },
+  });
+};
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 96c4d0e067032590c4aa79ffd4fc7f5f55a55220..ea6bca644edcbca6b7cc7afe10f6aedd2d77ebc8 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -1,4 +1,5 @@
 import { mount2faRegistration } from '~/authentication/mount_2fa';
+import { initWebAuthnRegistration } from '~/authentication/webauthn/registration';
 import { initRecoveryCodes, initManageTwoFactorForm } from '~/authentication/two_factor_auth';
 import { parseBoolean } from '~/lib/utils/common_utils';
 
@@ -15,6 +16,7 @@ if (skippable) {
 }
 
 mount2faRegistration();
+initWebAuthnRegistration();
 
 initRecoveryCodes();
 
diff --git a/app/helpers/device_registration_helper.rb b/app/helpers/device_registration_helper.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bbdcab76bf5268a5cb81b5a915f847af2699297e
--- /dev/null
+++ b/app/helpers/device_registration_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module DeviceRegistrationHelper
+  def device_registration_data(current_password_required:, target_path:, webauthn_error:)
+    {
+      initial_error: webauthn_error && webauthn_error[:message],
+      target_path: target_path,
+      password_required: current_password_required.to_s
+    }
+  end
+end
diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml
index d6fe20e48bfcabfa557088edaf7e7a953a0a41f4..dc4511a8159b6f2826866a6ede940c641c9db7f7 100644
--- a/app/views/authentication/_register.html.haml
+++ b/app/views/authentication/_register.html.haml
@@ -1,47 +1,50 @@
-#js-register-token-2fa
+- if Feature.enabled?(:webauthn) && Feature.enabled?(:webauthn_without_totp)
+  #js-device-registration{ data: device_registration_data(current_password_required: current_password_required?, target_path: target_path, webauthn_error: @webauthn_error) }
+- else
+  #js-register-token-2fa
 
--# haml-lint:disable InlineJavaScript
-%script#js-register-2fa-message{ type: "text/template" }
-  %p <%= message %>
+  -# haml-lint:disable InlineJavaScript
+  %script#js-register-2fa-message{ type: "text/template" }
+    %p <%= message %>
 
--# haml-lint:disable InlineJavaScript
-%script#js-register-token-2fa-setup{ type: "text/template" }
-  - if current_user.two_factor_otp_enabled?
-    .row.gl-mb-3
-      .col-md-5
-        = render Pajamas::ButtonComponent.new(variant: :confirm,
-          button_options: { id: 'js-setup-token-2fa-device' }) do
-          = _("Set up new device")
-      .col-md-7
-        %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.")
-  - else
-    .row.gl-mb-3
-      .col-md-4
-        = render Pajamas::ButtonComponent.new(variant: :confirm,
-          disabled: true,
-          button_options: { id: 'js-setup-token-2fa-device' }) do
-          = _("Set up new device")
-      .col-md-8
-        %p= _("You need to register a two-factor authentication app before you can set up a device.")
+  -# haml-lint:disable InlineJavaScript
+  %script#js-register-token-2fa-setup{ type: "text/template" }
+    - if current_user.two_factor_otp_enabled?
+      .row.gl-mb-3
+        .col-md-5
+          = render Pajamas::ButtonComponent.new(variant: :confirm,
+            button_options: { id: 'js-setup-token-2fa-device' }) do
+            = _("Set up new device")
+        .col-md-7
+          %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.")
+    - else
+      .row.gl-mb-3
+        .col-md-4
+          = render Pajamas::ButtonComponent.new(variant: :confirm,
+            disabled: true,
+            button_options: { id: 'js-setup-token-2fa-device' }) do
+            = _("Set up new device")
+        .col-md-8
+          %p= _("You need to register a two-factor authentication app before you can set up a device.")
 
--# haml-lint:disable InlineJavaScript
-%script#js-register-token-2fa-error{ type: "text/template" }
-  %div
-    %p
-      %span <%= error_message %> (<%= error_name %>)
-    = render Pajamas::ButtonComponent.new(button_options: { id: 'js-token-2fa-try-again' }) do
-      = _("Try again?")
+  -# haml-lint:disable InlineJavaScript
+  %script#js-register-token-2fa-error{ type: "text/template" }
+    %div
+      %p
+        %span <%= error_message %> (<%= error_name %>)
+      = render Pajamas::ButtonComponent.new(button_options: { id: 'js-token-2fa-try-again' }) do
+        = _("Try again?")
 
--# haml-lint:disable InlineJavaScript
-%script#js-register-token-2fa-registered{ type: "text/template" }
-  .row.gl-mb-3
-    .col-md-12
-      %p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
-      = form_tag(target_path, method: :post) do
-        .row.gl-mb-3
-          .col-md-3
-            = text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
-          .col-md-3
-            = hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
-            = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
-              = _("Register device")
+  -# haml-lint:disable InlineJavaScript
+  %script#js-register-token-2fa-registered{ type: "text/template" }
+    .row.gl-mb-3
+      .col-md-12
+        %p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
+        = form_tag(target_path, method: :post) do
+          .row.gl-mb-3
+            .col-md-3
+              = text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
+            .col-md-3
+              = hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
+              = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
+                = _("Register device")
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 8eabffd78b7129ac723010b13f68b65c40a4d817..39840e5a3132724468f20485a4187c046fb60974 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -14525,6 +14525,9 @@ msgstr ""
 msgid "Developer"
 msgstr ""
 
+msgid "Device name"
+msgstr ""
+
 msgid "Devices (optional)"
 msgstr ""
 
@@ -16887,6 +16890,9 @@ msgstr ""
 msgid "Exceptions"
 msgstr ""
 
+msgid "Excluding USB security keys, you should include the browser name together with the device name."
+msgstr ""
+
 msgid "Excluding merge commits. Limited to %{limit} commits."
 msgstr ""
 
@@ -25812,6 +25818,9 @@ msgstr ""
 msgid "MRDiff|Show full file"
 msgstr ""
 
+msgid "Macbook Touch ID on Edge"
+msgstr ""
+
 msgid "Machine Learning Experiment Tracking is in Incubating Phase"
 msgstr ""
 
@@ -49495,6 +49504,9 @@ msgstr ""
 msgid "You must provide your current password in order to change it."
 msgstr ""
 
+msgid "You must save your recovery codes after you first register a two-factor authenticator, so you do not lose access to your account. %{linkStart}See the documentation on managing your WebAuthn device for more information.%{linkEnd}"
+msgstr ""
+
 msgid "You must sign in to search for specific projects."
 msgstr ""
 
@@ -49827,6 +49839,9 @@ msgstr ""
 msgid "Your commit email is used for web based operations, such as edits and merges."
 msgstr ""
 
+msgid "Your current password is required to register a new device."
+msgstr ""
+
 msgid "Your current password is required to register a two-factor authenticator app."
 msgstr ""
 
diff --git a/spec/frontend/authentication/webauthn/components/registration_spec.js b/spec/frontend/authentication/webauthn/components/registration_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..56185c59b5a31f97ad7944e38a658cd6d09464bb
--- /dev/null
+++ b/spec/frontend/authentication/webauthn/components/registration_spec.js
@@ -0,0 +1,249 @@
+import { nextTick } from 'vue';
+import { GlAlert, GlButton, GlForm, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import Registration from '~/authentication/webauthn/components/registration.vue';
+import {
+  I18N_BUTTON_REGISTER,
+  I18N_BUTTON_SETUP,
+  I18N_BUTTON_TRY_AGAIN,
+  I18N_ERROR_HTTP,
+  I18N_ERROR_UNSUPPORTED_BROWSER,
+  I18N_INFO_TEXT,
+  I18N_STATUS_SUCCESS,
+  I18N_STATUS_WAITING,
+  STATE_ERROR,
+  STATE_READY,
+  STATE_SUCCESS,
+  STATE_UNSUPPORTED,
+  STATE_WAITING,
+} from '~/authentication/webauthn/constants';
+import * as WebAuthnUtils from '~/authentication/webauthn/util';
+
+const csrfToken = 'mock-csrf-token';
+jest.mock('~/lib/utils/csrf', () => ({ token: csrfToken }));
+jest.mock('~/authentication/webauthn/util');
+
+describe('Registration', () => {
+  const initialError = null;
+  const passwordRequired = true;
+  const targetPath = '/-/profile/two_factor_auth/create_webauthn';
+  let wrapper;
+
+  const createComponent = (provide = {}) => {
+    wrapper = shallowMountExtended(Registration, {
+      provide: { initialError, passwordRequired, targetPath, ...provide },
+    });
+  };
+
+  const findButton = () => wrapper.findComponent(GlButton);
+
+  describe(`when ${STATE_UNSUPPORTED} state`, () => {
+    it('shows an error if using unsecure scheme (HTTP)', () => {
+      WebAuthnUtils.isHTTPS.mockReturnValue(false);
+      WebAuthnUtils.supported.mockReturnValue(true);
+      createComponent();
+
+      const alert = wrapper.findComponent(GlAlert);
+      expect(alert.props('variant')).toBe('danger');
+      expect(alert.text()).toBe(I18N_ERROR_HTTP);
+    });
+
+    it('shows an error if using unsupported browser', () => {
+      WebAuthnUtils.isHTTPS.mockReturnValue(true);
+      WebAuthnUtils.supported.mockReturnValue(false);
+      createComponent();
+
+      const alert = wrapper.findComponent(GlAlert);
+      expect(alert.props('variant')).toBe('danger');
+      expect(alert.text()).toBe(I18N_ERROR_UNSUPPORTED_BROWSER);
+    });
+  });
+
+  describe('when scheme or browser are supported', () => {
+    const mockCreate = jest.fn();
+
+    const clickSetupDeviceButton = () => {
+      findButton().vm.$emit('click');
+      return nextTick();
+    };
+
+    const setupDevice = () => {
+      clickSetupDeviceButton();
+      return waitForPromises();
+    };
+
+    beforeEach(() => {
+      WebAuthnUtils.isHTTPS.mockReturnValue(true);
+      WebAuthnUtils.supported.mockReturnValue(true);
+      global.navigator.credentials = { create: mockCreate };
+      gon.webauthn = { options: {} };
+    });
+
+    afterEach(() => {
+      global.navigator.credentials = undefined;
+    });
+
+    describe(`when ${STATE_READY} state`, () => {
+      it('shows button and explanation text', () => {
+        createComponent();
+
+        expect(findButton().text()).toBe(I18N_BUTTON_SETUP);
+        expect(wrapper.text()).toContain(I18N_INFO_TEXT);
+      });
+    });
+
+    describe(`when ${STATE_WAITING} state`, () => {
+      it('shows loading icon and message after pressing the button', async () => {
+        createComponent();
+
+        await clickSetupDeviceButton();
+
+        expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+        expect(wrapper.text()).toContain(I18N_STATUS_WAITING);
+      });
+    });
+
+    describe(`when ${STATE_SUCCESS} state`, () => {
+      const credentials = 1;
+
+      const findCurrentPasswordInput = () => wrapper.findByTestId('current-password-input');
+      const findDeviceNameInput = () => wrapper.findByTestId('device-name-input');
+
+      beforeEach(() => {
+        mockCreate.mockResolvedValueOnce(true);
+        WebAuthnUtils.convertCreateResponse.mockReturnValue(credentials);
+      });
+
+      describe('registration form', () => {
+        it('has correct action', async () => {
+          createComponent();
+
+          await setupDevice();
+
+          expect(wrapper.findComponent(GlForm).attributes('action')).toBe(targetPath);
+        });
+
+        describe('when password is required', () => {
+          it('shows device name and password fields', async () => {
+            createComponent();
+
+            await setupDevice();
+
+            expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS);
+
+            // Visible inputs
+            expect(findCurrentPasswordInput().attributes('name')).toBe('current_password');
+            expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]');
+
+            // Hidden inputs
+            expect(
+              wrapper
+                .find('input[name="device_registration[device_response]"]')
+                .attributes('value'),
+            ).toBe(`${credentials}`);
+            expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe(
+              csrfToken,
+            );
+
+            expect(findButton().text()).toBe(I18N_BUTTON_REGISTER);
+          });
+
+          it('enables the register device button when device name and password are filled', async () => {
+            createComponent();
+
+            await setupDevice();
+
+            expect(findButton().props('disabled')).toBe(true);
+
+            // Visible inputs
+            findCurrentPasswordInput().vm.$emit('input', 'my current password');
+            findDeviceNameInput().vm.$emit('input', 'my device name');
+            await nextTick();
+
+            expect(findButton().props('disabled')).toBe(false);
+          });
+        });
+
+        describe('when password is not required', () => {
+          it('shows a device name field', async () => {
+            createComponent({ passwordRequired: false });
+
+            await setupDevice();
+
+            expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS);
+
+            // Visible inputs
+            expect(findCurrentPasswordInput().exists()).toBe(false);
+            expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]');
+
+            // Hidden inputs
+            expect(
+              wrapper
+                .find('input[name="device_registration[device_response]"]')
+                .attributes('value'),
+            ).toBe(`${credentials}`);
+            expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe(
+              csrfToken,
+            );
+
+            expect(findButton().text()).toBe(I18N_BUTTON_REGISTER);
+          });
+
+          it('enables the register device button when device name is filled', async () => {
+            createComponent({ passwordRequired: false });
+
+            await setupDevice();
+
+            expect(findButton().props('disabled')).toBe(true);
+
+            findDeviceNameInput().vm.$emit('input', 'my device name');
+            await nextTick();
+
+            expect(findButton().props('disabled')).toBe(false);
+          });
+        });
+      });
+    });
+
+    describe(`when ${STATE_ERROR} state`, () => {
+      it('shows an initial error message and a retry button', async () => {
+        const myError = 'my error';
+        createComponent({ initialError: myError });
+
+        const alert = wrapper.findComponent(GlAlert);
+        expect(alert.props()).toMatchObject({
+          variant: 'danger',
+          secondaryButtonText: I18N_BUTTON_TRY_AGAIN,
+        });
+        expect(alert.text()).toContain(myError);
+      });
+
+      it('shows an error message and a retry button', async () => {
+        createComponent();
+        mockCreate.mockRejectedValueOnce(new Error());
+
+        await setupDevice();
+
+        expect(wrapper.findComponent(GlAlert).props()).toMatchObject({
+          variant: 'danger',
+          secondaryButtonText: I18N_BUTTON_TRY_AGAIN,
+        });
+      });
+
+      it('recovers after an error (error to success state)', async () => {
+        createComponent();
+        mockCreate.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(true);
+
+        await setupDevice();
+
+        expect(wrapper.findComponent(GlAlert).props('variant')).toBe('danger');
+
+        wrapper.findComponent(GlAlert).vm.$emit('secondaryAction');
+        await waitForPromises();
+
+        expect(wrapper.findComponent(GlAlert).props('variant')).toBe('info');
+      });
+    });
+  });
+});
diff --git a/spec/frontend/fixtures/webauthn.rb b/spec/frontend/fixtures/webauthn.rb
index c6e9b41b584deb19d4b9d7595ca977a7fbe8adbf..ed6180118f008ff9502755fec3c11b6483393420 100644
--- a/spec/frontend/fixtures/webauthn.rb
+++ b/spec/frontend/fixtures/webauthn.rb
@@ -32,6 +32,7 @@
       allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance|
         allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares')
       end
+      stub_feature_flags(webauthn_without_totp: false)
     end
 
     it 'webauthn/register.html' do
diff --git a/spec/helpers/device_registration_helper_spec.rb b/spec/helpers/device_registration_helper_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a8222cddca914f778e6b44afafc0dbc9b4f4f3c9
--- /dev/null
+++ b/spec/helpers/device_registration_helper_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe DeviceRegistrationHelper, feature_category: :authentication_and_authorization do
+  describe "#device_registration_data" do
+    it "returns a hash with device registration properties without initial error" do
+      device_registration_data = helper.device_registration_data(
+        current_password_required: false,
+        target_path: "/my/path",
+        webauthn_error: nil
+      )
+
+      expect(device_registration_data).to eq(
+        {
+          initial_error: nil,
+          target_path: "/my/path",
+          password_required: "false"
+        })
+    end
+
+    it "returns a hash with device registration properties with initial error" do
+      device_registration_data = helper.device_registration_data(
+        current_password_required: true,
+        target_path: "/my/path",
+        webauthn_error: { message: "my error" }
+      )
+
+      expect(device_registration_data).to eq(
+        {
+          initial_error: "my error",
+          target_path: "/my/path",
+          password_required: "true"
+        })
+    end
+  end
+end