diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css
new file mode 100644
index 000000000..82c9ef32c
--- /dev/null
+++ b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css
@@ -0,0 +1,18 @@
+/* Copyright 2023 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+.alert {
+ margin-top: var(--cpd-space-4x);
+}
\ No newline at end of file
diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx
new file mode 100644
index 000000000..bb254274d
--- /dev/null
+++ b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx
@@ -0,0 +1,95 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// @vitest-environment happy-dom
+
+import { render, cleanup, fireEvent } from "@testing-library/react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { makeFragmentData } from "../../gql/fragment-masking";
+import { WithLocation } from "../../test-utils/WithLocation";
+
+import UnverifiedEmailAlert, {
+ UNVERIFIED_EMAILS_FRAGMENT,
+} from "./UnverifiedEmailAlert";
+
+describe("", () => {
+ afterEach(cleanup);
+
+ it("does not render a warning when there are no unverified emails", () => {
+ const data = makeFragmentData(
+ {
+ id: "abc123",
+ unverifiedEmails: {
+ totalCount: 0,
+ },
+ },
+ UNVERIFIED_EMAILS_FRAGMENT,
+ );
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container).toMatchInlineSnapshot("
");
+ });
+
+ it("renders a warning when there are unverified emails", () => {
+ const data = makeFragmentData(
+ {
+ id: "abc123",
+ unverifiedEmails: {
+ totalCount: 2,
+ },
+ },
+ UNVERIFIED_EMAILS_FRAGMENT,
+ );
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it("hides warning after it has been dismissed", () => {
+ const data = makeFragmentData(
+ {
+ id: "abc123",
+ unverifiedEmails: {
+ totalCount: 2,
+ },
+ },
+ UNVERIFIED_EMAILS_FRAGMENT,
+ );
+
+ const { container, getByText, getByLabelText } = render(
+
+
+ ,
+ );
+
+ // warning is rendered
+ expect(getByText("Unverified email")).toBeTruthy();
+
+ fireEvent.click(getByLabelText("Close"));
+
+ // no more warning
+ expect(container).toMatchInlineSnapshot("");
+ });
+});
diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx
new file mode 100644
index 000000000..fe3581e0b
--- /dev/null
+++ b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx
@@ -0,0 +1,60 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Alert } from "@vector-im/compound-web";
+import { useState } from "react";
+
+import { Link } from "../../Router";
+import { FragmentType, useFragment } from "../../gql/fragment-masking";
+import { graphql } from "../../gql/gql";
+
+import styles from "./UnverifiedEmailAlert.module.css";
+
+export const UNVERIFIED_EMAILS_FRAGMENT = graphql(/* GraphQL */ `
+ fragment UnverifiedEmailAlert on User {
+ id
+ unverifiedEmails: emails(first: 0, state: PENDING) {
+ totalCount
+ }
+ }
+`);
+
+const UnverifiedEmailAlert: React.FC<{
+ unverifiedEmails?: FragmentType;
+}> = ({ unverifiedEmails }) => {
+ const data = useFragment(UNVERIFIED_EMAILS_FRAGMENT, unverifiedEmails);
+ const [dismiss, setDismiss] = useState(false);
+
+ const doDismiss = (): void => setDismiss(true);
+
+ if (!data?.unverifiedEmails?.totalCount || dismiss) {
+ return null;
+ }
+
+ return (
+
+ You have {data.unverifiedEmails.totalCount} unverified email address(es).{" "}
+
+ Review and verify
+
+
+ );
+};
+
+export default UnverifiedEmailAlert;
diff --git a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap
new file mode 100644
index 000000000..f6fdd7623
--- /dev/null
+++ b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap
@@ -0,0 +1,61 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders a warning when there are unverified emails 1`] = `
+