diff --git a/app/assets/javascripts/discourse/app/components/modal/associate-account-confirm.hbs b/app/assets/javascripts/discourse/app/components/modal/associate-account-confirm.hbs
new file mode 100644
index 00000000000..fcd33674869
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/modal/associate-account-confirm.hbs
@@ -0,0 +1,46 @@
+<DModal
+  @title={{i18n
+    "user.associated_accounts.confirm_modal_title"
+    provider=(i18n (concat "login." @model.provider_name ".name"))
+  }}
+  @closeModal={{@closeModal}}
+  @flash={{this.flash}}
+  @flashType="error"
+>
+  <:body>
+    {{#if @model.existing_account_description}}
+      <p>
+        {{i18n
+          "user.associated_accounts.confirm_description.disconnect"
+          provider=(i18n (concat "login." @model.provider_name ".name"))
+          account_description=@model.existing_account_description
+        }}
+      </p>
+    {{/if}}
+
+    <p>
+      {{#if @model.account_description}}
+        {{i18n
+          "user.associated_accounts.confirm_description.account_specific"
+          provider=(i18n (concat "login." @model.provider_name ".name"))
+          account_description=@model.account_description
+        }}
+      {{else}}
+        {{i18n
+          "user.associated_accounts.confirm_description.generic"
+          provider=(i18n (concat "login." @model.provider_name ".name"))
+        }}
+      {{/if}}
+    </p>
+  </:body>
+
+  <:footer>
+    <DButton
+      @label="user.associated_accounts.connect"
+      @action={{this.finishConnect}}
+      @icon="plug"
+      class="btn-primary"
+    />
+    <DButton @label="user.associated_accounts.cancel" @action={{@closeModal}} />
+  </:footer>
+</DModal>
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/modal/associate-account-confirm.js b/app/assets/javascripts/discourse/app/components/modal/associate-account-confirm.js
new file mode 100644
index 00000000000..3857e20c2b1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/modal/associate-account-confirm.js
@@ -0,0 +1,35 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+
+export default class AssociateAccountConfirm extends Component {
+  @service router;
+  @service currentUser;
+
+  @tracked flash;
+
+  @action
+  async finishConnect() {
+    try {
+      const result = await ajax({
+        url: `/associate/${encodeURIComponent(this.args.model.token)}`,
+        type: "POST",
+      });
+
+      if (result.success) {
+        this.router.transitionTo(
+          "preferences.account",
+          this.currentUser.findDetails()
+        );
+        this.args.closeModal();
+      } else {
+        this.flash = result.error;
+      }
+    } catch (e) {
+      popupAjaxError(e);
+    }
+  }
+}
diff --git a/app/assets/javascripts/discourse/app/controllers/associate-account-confirm.js b/app/assets/javascripts/discourse/app/controllers/associate-account-confirm.js
deleted file mode 100644
index c76f1f2dc27..00000000000
--- a/app/assets/javascripts/discourse/app/controllers/associate-account-confirm.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import Controller from "@ember/controller";
-import { inject as service } from "@ember/service";
-import { ajax } from "discourse/lib/ajax";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import ModalFunctionality from "discourse/mixins/modal-functionality";
-
-export default Controller.extend(ModalFunctionality, {
-  router: service(),
-  currentUser: service(),
-
-  actions: {
-    finishConnect() {
-      ajax({
-        url: `/associate/${encodeURIComponent(this.model.token)}`,
-        type: "POST",
-      })
-        .then((result) => {
-          if (result.success) {
-            this.router.transitionTo(
-              "preferences.account",
-              this.currentUser.findDetails()
-            );
-            this.send("closeModal");
-          } else {
-            this.set("model.error", result.error);
-          }
-        })
-        .catch(popupAjaxError);
-    },
-  },
-});
diff --git a/app/assets/javascripts/discourse/app/routes/associate-account.js b/app/assets/javascripts/discourse/app/routes/associate-account.js
index 36171170104..a27c2cfb1c9 100644
--- a/app/assets/javascripts/discourse/app/routes/associate-account.js
+++ b/app/assets/javascripts/discourse/app/routes/associate-account.js
@@ -1,15 +1,16 @@
 import { action } from "@ember/object";
 import { next } from "@ember/runloop";
 import { inject as service } from "@ember/service";
+import AssociateAccountConfirm from "discourse/components/modal/associate-account-confirm";
 import { ajax } from "discourse/lib/ajax";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import cookie from "discourse/lib/cookie";
-import showModal from "discourse/lib/show-modal";
 import DiscourseRoute from "discourse/routes/discourse";
 
 export default DiscourseRoute.extend({
   router: service(),
   currentUser: service(),
+  modal: service(),
 
   beforeModel(transition) {
     if (!this.currentUser) {
@@ -34,7 +35,7 @@ export default DiscourseRoute.extend({
       const model = await ajax(
         `/associate/${encodeURIComponent(params.token)}.json`
       );
-      showModal("associate-account-confirm", { model });
+      this.modal.show(AssociateAccountConfirm, { model });
     } catch (e) {
       popupAjaxError(e);
     }
diff --git a/app/assets/javascripts/discourse/app/services/modal.js b/app/assets/javascripts/discourse/app/services/modal.js
index 5bc879e3e3c..bfc5ea96d0e 100644
--- a/app/assets/javascripts/discourse/app/services/modal.js
+++ b/app/assets/javascripts/discourse/app/services/modal.js
@@ -14,7 +14,6 @@ import I18n from "discourse-i18n";
 // Known legacy modals in core. Silence deprecation warnings for these so the messages
 // don't cause unnecessary noise.
 const KNOWN_LEGACY_MODALS = [
-  "associate-account-confirm",
   "avatar-selector",
   "change-owner",
   "change-post-notice",
diff --git a/app/assets/javascripts/discourse/app/templates/modal/associate-account-confirm.hbs b/app/assets/javascripts/discourse/app/templates/modal/associate-account-confirm.hbs
deleted file mode 100644
index 84169f02da4..00000000000
--- a/app/assets/javascripts/discourse/app/templates/modal/associate-account-confirm.hbs
+++ /dev/null
@@ -1,50 +0,0 @@
-<DModalBody
-  @rawTitle={{i18n
-    "user.associated_accounts.confirm_modal_title"
-    provider=(i18n (concat "login." this.model.provider_name ".name"))
-  }}
->
-  {{#if this.model.error}}
-    <div class="alert alert-error">
-      {{this.model.error}}
-    </div>
-  {{/if}}
-
-  {{#if this.model.existing_account_description}}
-    <p>
-      {{i18n
-        "user.associated_accounts.confirm_description.disconnect"
-        provider=(i18n (concat "login." this.model.provider_name ".name"))
-        account_description=this.model.existing_account_description
-      }}
-    </p>
-  {{/if}}
-
-  <p>
-    {{#if this.model.account_description}}
-      {{i18n
-        "user.associated_accounts.confirm_description.account_specific"
-        provider=(i18n (concat "login." this.model.provider_name ".name"))
-        account_description=this.model.account_description
-      }}
-    {{else}}
-      {{i18n
-        "user.associated_accounts.confirm_description.generic"
-        provider=(i18n (concat "login." this.model.provider_name ".name"))
-      }}
-    {{/if}}
-  </p>
-</DModalBody>
-
-<div class="modal-footer">
-  <DButton
-    @label="user.associated_accounts.connect"
-    @action={{action "finishConnect"}}
-    @icon="plug"
-    class="btn-primary"
-  />
-  <DButton
-    @label="user.associated_accounts.cancel"
-    @action={{action "closeModal"}}
-  />
-</div>
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js b/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js
index ccabacfdd12..fa928c3bb68 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js
@@ -1,6 +1,7 @@
-import { click, fillIn, visit } from "@ember/test-helpers";
+import { click, currentURL, fillIn, visit } from "@ember/test-helpers";
 import { test } from "qunit";
 import PreloadStore from "discourse/lib/preload-store";
+import pretender, { response } from "discourse/tests/helpers/create-pretender";
 import {
   acceptance,
   exists,
@@ -504,3 +505,76 @@ acceptance(
     });
   }
 );
+
+acceptance("Associate link", function (needs) {
+  needs.user();
+  needs.settings({ enable_local_logins: false });
+
+  setAuthenticationData(needs.hooks, {
+    auth_provider: "facebook",
+    email: "blah@example.com",
+    email_valid: true,
+    username: "foobar",
+    name: "barfoo",
+    associate_url: "/associate/abcde",
+  });
+
+  test("associates the account", async function (assert) {
+    preloadInvite({ link: true });
+    pretender.get("/associate/abcde.json", () => {
+      return response({
+        token: "abcde",
+        provider_name: "facebook",
+      });
+    });
+
+    pretender.post("/associate/abcde", () => {
+      return response({ success: true });
+    });
+
+    await visit("/invites/my-valid-invite-token");
+    assert
+      .dom(".create-account-associate-link")
+      .exists("shows the associate account link");
+
+    await click(".create-account-associate-link a");
+    assert.dom(".d-modal").exists();
+
+    await click(".d-modal .btn-primary");
+    assert.strictEqual(currentURL(), "/u/eviltrout/preferences/account");
+  });
+});
+
+acceptance("Associate link, with an error", function (needs) {
+  needs.user();
+  needs.settings({ enable_local_logins: false });
+
+  setAuthenticationData(needs.hooks, {
+    auth_provider: "facebook",
+    email: "blah@example.com",
+    email_valid: true,
+    username: "foobar",
+    name: "barfoo",
+    associate_url: "/associate/abcde",
+  });
+
+  test("shows the error", async function (assert) {
+    preloadInvite({ link: true });
+    pretender.get("/associate/abcde.json", () => {
+      return response({
+        token: "abcde",
+        provider_name: "facebook",
+      });
+    });
+
+    pretender.post("/associate/abcde", () => {
+      return response({ error: "sorry, no" });
+    });
+
+    await visit("/invites/my-valid-invite-token");
+    await click(".create-account-associate-link a");
+    await click(".d-modal .btn-primary");
+
+    assert.dom(".d-modal .alert").hasText("sorry, no");
+  });
+});
diff --git a/app/assets/javascripts/discourse/tests/acceptance/modal/login-test.js b/app/assets/javascripts/discourse/tests/acceptance/modal/login-test.js
deleted file mode 100644
index c4ba17b2df5..00000000000
--- a/app/assets/javascripts/discourse/tests/acceptance/modal/login-test.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { click, tab, visit } from "@ember/test-helpers";
-import { test } from "qunit";
-import { acceptance } from "discourse/tests/helpers/qunit-helpers";
-
-acceptance("Modal - Login", function () {
-  test("You can tab to the login button", async function (assert) {
-    await visit("/");
-    await click("header .login-button");
-    // you have to press the tab key twice to get to the login button
-    await tab({ unRestrainTabIndex: true });
-    await tab({ unRestrainTabIndex: true });
-    assert.dom(".modal-footer #login-button").isFocused();
-  });
-});