From aa05f5146a9cde95bb00bc44fc788c319c6cf847 Mon Sep 17 00:00:00 2001
From: enmande <3836813+enmande@users.noreply.github.com>
Date: Wed, 12 Aug 2026 14:52:11 -0400
Subject: [PATCH 1/7] feat(verify-email): Add "verified" and CTA inline with
user email.
---
.../settings/account/profile.component.html | 27 +++++++++++++++++++
.../settings/account/profile.component.ts | 8 ++++++
2 files changed, 35 insertions(+)
diff --git a/apps/web/src/app/auth/settings/account/profile.component.html b/apps/web/src/app/auth/settings/account/profile.component.html
index c44529726447..205823386df5 100644
--- a/apps/web/src/app/auth/settings/account/profile.component.html
+++ b/apps/web/src/app/auth/settings/account/profile.component.html
@@ -19,6 +19,33 @@
{{ "email" | i18n }}
+ @if (profile.emailVerified) {
+
+ {{ "verified" | i18n }}
+
+ } @else {
+
+ }
diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts
index 6d4903cd1453..111007b1e9ef 100644
--- a/apps/web/src/app/auth/settings/account/profile.component.ts
+++ b/apps/web/src/app/auth/settings/account/profile.component.ts
@@ -114,4 +114,12 @@ export class ProfileComponent implements OnInit {
message: this.i18nService.t("accountUpdated"),
});
};
+
+ protected readonly verifyEmail = async () => {
+ await this.apiService.postAccountVerifyEmail();
+ this.toastService.showToast({
+ variant: "success",
+ message: this.i18nService.t("checkInboxForVerification"),
+ });
+ };
}
From e6511b39b78d9d5886f0a1c5cb5a303824caa2e3 Mon Sep 17 00:00:00 2001
From: enmande <3836813+enmande@users.noreply.github.com>
Date: Wed, 12 Aug 2026 14:55:09 -0400
Subject: [PATCH 2/7] feat(verify-email): Remove verify-email banner; no longer
used.
---
.../auth/settings/verify-email.component.html | 13 ----
.../auth/settings/verify-email.component.ts | 67 -------------------
.../services/vault-banners.service.spec.ts | 44 +-----------
.../services/vault-banners.service.ts | 18 +----
.../vault-banners.component.html | 8 ---
.../vault-banners.component.spec.ts | 17 +----
.../vault-banners/vault-banners.component.ts | 10 +--
7 files changed, 4 insertions(+), 173 deletions(-)
delete mode 100644 apps/web/src/app/auth/settings/verify-email.component.html
delete mode 100644 apps/web/src/app/auth/settings/verify-email.component.ts
diff --git a/apps/web/src/app/auth/settings/verify-email.component.html b/apps/web/src/app/auth/settings/verify-email.component.html
deleted file mode 100644
index 4ecbaf49ccd2..000000000000
--- a/apps/web/src/app/auth/settings/verify-email.component.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
- {{ "verifyEmailDesc" | i18n }}
-
-
diff --git a/apps/web/src/app/auth/settings/verify-email.component.ts b/apps/web/src/app/auth/settings/verify-email.component.ts
deleted file mode 100644
index a63d0b18b368..000000000000
--- a/apps/web/src/app/auth/settings/verify-email.component.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-// FIXME: Update this file to be type safe and remove this and next line
-// @ts-strict-ignore
-import { CommonModule } from "@angular/common";
-import { Component, EventEmitter, Output } from "@angular/core";
-
-import { JslibModule } from "@bitwarden/angular/jslib.module";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
-import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
-import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
-import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
-import {
- AsyncActionsModule,
- BannerModule,
- ButtonModule,
- LinkModule,
- ToastService,
-} from "@bitwarden/components";
-
-// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
-// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
-@Component({
- selector: "app-verify-email",
- templateUrl: "verify-email.component.html",
- imports: [AsyncActionsModule, BannerModule, ButtonModule, CommonModule, JslibModule, LinkModule],
-})
-export class VerifyEmailComponent {
- actionPromise: Promise
;
-
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
- @Output() onVerified = new EventEmitter();
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
- @Output() onDismiss = new EventEmitter();
-
- constructor(
- private apiService: ApiService,
- private i18nService: I18nService,
- private platformUtilsService: PlatformUtilsService,
- private tokenService: TokenService,
- private toastService: ToastService,
- ) {}
-
- async verifyEmail(): Promise {
- await this.apiService.refreshIdentityToken();
- if (await this.tokenService.getEmailVerified()) {
- this.onVerified.emit(true);
- this.toastService.showToast({
- variant: "success",
- title: null,
- message: this.i18nService.t("emailVerified"),
- });
- return;
- }
-
- await this.apiService.postAccountVerifyEmail();
- this.toastService.showToast({
- variant: "success",
- title: null,
- message: this.i18nService.t("checkInboxForVerification"),
- });
- }
-
- send = async () => {
- await this.verifyEmail();
- };
-}
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts
index 20148018c392..eb1e3de74eac 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts
@@ -2,18 +2,13 @@ import { TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
-import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DeviceType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
-import {
- FakeStateProvider,
- mockAccountServiceWith,
- mockAccountInfoWith,
-} from "@bitwarden/common/spec";
+import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@@ -27,12 +22,6 @@ describe("VaultBannersService", () => {
const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
const getEmailVerified = jest.fn().mockResolvedValue(true);
const lastSync$ = new BehaviorSubject(null);
- const accounts$ = new BehaviorSubject({
- [userId]: mockAccountInfoWith({
- email: "test@bitwarden.com",
- name: "name",
- }),
- });
const pendingAuthRequests$ = new BehaviorSubject>([]);
beforeEach(() => {
@@ -55,10 +44,6 @@ describe("VaultBannersService", () => {
provide: StateProvider,
useValue: fakeStateProvider,
},
- {
- provide: AccountService,
- useValue: { accounts$ },
- },
{
provide: SyncService,
useValue: { lastSync$: () => lastSync$ },
@@ -102,33 +87,6 @@ describe("VaultBannersService", () => {
});
});
- describe("VerifyEmail", () => {
- beforeEach(async () => {
- accounts$.next({
- [userId]: {
- ...accounts$.value[userId],
- emailVerified: false,
- },
- });
- });
-
- it("shows verify email banner", async () => {
- service = TestBed.inject(VaultBannersService);
-
- expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(true);
- });
-
- it("dismisses verify email banner", async () => {
- service = TestBed.inject(VaultBannersService);
-
- expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(true);
-
- await service.dismissBanner(userId, VisibleVaultBanner.VerifyEmail);
-
- expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(false);
- });
- });
-
describe("PendingAuthRequest", () => {
const now = new Date();
let authRequestResponse: AuthRequestResponse;
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts
index 6371f78c0f54..cb35d09d4611 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts
@@ -1,8 +1,7 @@
import { Injectable } from "@angular/core";
-import { firstValueFrom, map } from "rxjs";
+import { firstValueFrom } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
-import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
@@ -17,7 +16,6 @@ import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
export const VisibleVaultBanner = {
OutdatedBrowser: "outdated-browser",
- VerifyEmail: "verify-email",
PendingAuthRequest: "pending-auth-request",
} as const;
@@ -38,7 +36,6 @@ export const BANNERS_DISMISSED_DISK_KEY = new UserKeyDefinition {
- const needsVerification = !(
- await firstValueFrom(this.accountService.accounts$.pipe(map((accounts) => accounts[userId])))
- )?.emailVerified;
-
- const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
- VisibleVaultBanner.VerifyEmail,
- );
-
- return needsVerification && !alreadyDismissed;
- }
-
/** Dismiss the given banner and perform any respective side effects */
async dismissBanner(userId: UserId, banner: SessionBanners): Promise {
await this.sessionBannerState(userId).update((current) => {
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html
index 1612a3c60dcb..321d71ecb57e 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html
@@ -39,11 +39,3 @@
}
-
-@if (visibleBanners.includes(VisibleVaultBanner.VerifyEmail)) {
-
-}
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts
index 0592e4d72cf8..eb89d6ad7e74 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts
@@ -15,7 +15,6 @@ import { UserId } from "@bitwarden/common/types/guid";
import { BannerModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
-import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
import { SharedModule } from "../../../shared";
import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service";
@@ -31,7 +30,6 @@ describe("VaultBannersComponent", () => {
const bannerService = mock({
shouldShowUpdateBrowserBanner: jest.fn(),
- shouldShowVerifyEmailBanner: jest.fn(),
shouldShowPendingAuthRequestBanner: jest.fn((userId: UserId) =>
Promise.resolve(pendingAuthRequest$.value),
),
@@ -43,19 +41,11 @@ describe("VaultBannersComponent", () => {
beforeEach(async () => {
messageSubject = new Subject<{ command: string }>();
bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false);
- bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false);
pendingAuthRequest$.next(false);
premiumBanner$.next(false);
await TestBed.configureTestingModule({
- imports: [
- BannerModule,
- SharedModule,
- VerifyEmailComponent,
- VaultBannersComponent,
- RouterTestingModule,
- I18nPipe,
- ],
+ imports: [BannerModule, SharedModule, VaultBannersComponent, RouterTestingModule, I18nPipe],
providers: [
{
provide: I18nService,
@@ -103,11 +93,6 @@ describe("VaultBannersComponent", () => {
method: bannerService.shouldShowUpdateBrowserBanner,
banner: VisibleVaultBanner.OutdatedBrowser,
},
- {
- name: "VerifyEmail",
- method: bannerService.shouldShowVerifyEmailBanner,
- banner: VisibleVaultBanner.VerifyEmail,
- },
].forEach(({ name, method, banner }) => {
describe(name, () => {
beforeEach(async () => {
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts
index 9ddfdedd61b6..f2382b2699a7 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts
@@ -9,7 +9,6 @@ import { MessageListener } from "@bitwarden/common/platform/messaging";
import { BannerModule } from "@bitwarden/components";
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
-import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
import { SharedModule } from "../../../shared";
import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service";
@@ -19,12 +18,7 @@ import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banner
@Component({
selector: "app-vault-banners",
templateUrl: "./vault-banners.component.html",
- imports: [
- VerifyEmailComponent,
- SharedModule,
- BannerModule,
- OrganizationFreeTrialWarningComponent,
- ],
+ imports: [SharedModule, BannerModule, OrganizationFreeTrialWarningComponent],
providers: [VaultBannersService],
})
export class VaultBannersComponent implements OnInit {
@@ -89,13 +83,11 @@ export class VaultBannersComponent implements OnInit {
const showBrowserOutdated =
await this.vaultBannerService.shouldShowUpdateBrowserBanner(activeUserId);
- const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner(activeUserId);
const showPendingAuthRequest =
await this.vaultBannerService.shouldShowPendingAuthRequestBanner(activeUserId);
this.visibleBanners = [
showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null,
- showVerifyEmail ? VisibleVaultBanner.VerifyEmail : null,
showPendingAuthRequest ? VisibleVaultBanner.PendingAuthRequest : null,
].filter((banner) => banner !== null);
}
From 380475ab8f32728a71ec702e581d6fe176fdd29e Mon Sep 17 00:00:00 2001
From: enmande <3836813+enmande@users.noreply.github.com>
Date: Wed, 12 Aug 2026 14:56:13 -0400
Subject: [PATCH 3/7] feat(verify-email): Remove verifyEmailDesc localization
key; no longer used.
---
apps/web/src/locales/en/messages.json | 3 ---
1 file changed, 3 deletions(-)
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index e32ee1fd355e..30be88e94cfd 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -6390,9 +6390,6 @@
"verifyEmail": {
"message": "Verify email"
},
- "verifyEmailDesc": {
- "message": "Verify your account's email address to unlock access to all features."
- },
"verifyEmailFirst": {
"message": "Your account's email address first must be verified."
},
From b21dffb4db4873efc60c2f0d5e25b7abec70e338 Mon Sep 17 00:00:00 2001
From: enmande <3836813+enmande@users.noreply.github.com>
Date: Wed, 12 Aug 2026 15:03:26 -0400
Subject: [PATCH 4/7] test(verify-email): Add profile component test.
---
.../account/profile.component.spec.ts | 109 ++++++++++++++++++
1 file changed, 109 insertions(+)
create mode 100644 apps/web/src/app/auth/settings/account/profile.component.spec.ts
diff --git a/apps/web/src/app/auth/settings/account/profile.component.spec.ts b/apps/web/src/app/auth/settings/account/profile.component.spec.ts
new file mode 100644
index 000000000000..52ec2557ce2c
--- /dev/null
+++ b/apps/web/src/app/auth/settings/account/profile.component.spec.ts
@@ -0,0 +1,109 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { mock } from "jest-mock-extended";
+import { of } from "rxjs";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
+import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
+import { UserId } from "@bitwarden/common/types/guid";
+import { DialogService, ToastService } from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
+
+import { ProfileComponent } from "./profile.component";
+
+describe("ProfileComponent", () => {
+ let component: ProfileComponent;
+ let fixture: ComponentFixture;
+ let apiService: ReturnType>;
+ let toastService: ReturnType>;
+ let accountService: FakeAccountService;
+
+ const userId = "user-id" as UserId;
+
+ function buildProfile(emailVerified: boolean): ProfileResponse {
+ return new ProfileResponse({
+ Id: userId,
+ Name: "Test User",
+ Email: "test@bitwarden.com",
+ EmailVerified: emailVerified,
+ });
+ }
+
+ beforeEach(async () => {
+ apiService = mock();
+ toastService = mock();
+ accountService = mockAccountServiceWith(userId);
+
+ apiService.getProfile.mockResolvedValue(buildProfile(false));
+
+ await TestBed.configureTestingModule({
+ imports: [ProfileComponent],
+ providers: [
+ { provide: ApiService, useValue: apiService },
+ {
+ provide: OrganizationService,
+ useValue: mock({ organizations$: () => of([]) }),
+ },
+ { provide: AccountService, useValue: accountService },
+ { provide: I18nService, useValue: { t: (key: string) => key } },
+ { provide: LogService, useValue: mock() },
+ { provide: DialogService, useValue: mock() },
+ { provide: ToastService, useValue: toastService },
+ { provide: KeyService, useValue: mock({ userPublicKey$: () => of(null) }) },
+ { provide: AvatarService, useValue: mock({ avatarColor$: of(null) }) },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ProfileComponent);
+ component = fixture.componentInstance;
+ });
+
+ describe("email verification indicator", () => {
+ it("shows the verified badge when the email is verified", async () => {
+ apiService.getProfile.mockResolvedValue(buildProfile(true));
+
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ const badge = fixture.debugElement.nativeElement.querySelector("[bitbadge]");
+ const verifyButton = fixture.debugElement.nativeElement.querySelector(
+ "#profile_button_verifyEmail",
+ );
+
+ expect(badge).not.toBeNull();
+ expect(verifyButton).toBeNull();
+ });
+
+ it("shows the verify email button when the email is not verified", async () => {
+ apiService.getProfile.mockResolvedValue(buildProfile(false));
+
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ const badge = fixture.debugElement.nativeElement.querySelector("[bitbadge]");
+ const verifyButton = fixture.debugElement.nativeElement.querySelector(
+ "#profile_button_verifyEmail",
+ );
+
+ expect(badge).toBeNull();
+ expect(verifyButton).not.toBeNull();
+ });
+ });
+
+ describe("verifyEmail", () => {
+ it("sends the verification email and shows a success toast", async () => {
+ await component["verifyEmail"]();
+
+ expect(apiService.postAccountVerifyEmail).toHaveBeenCalled();
+ expect(toastService.showToast).toHaveBeenCalledWith({
+ variant: "success",
+ message: "checkInboxForVerification",
+ });
+ });
+ });
+});
From bd664a4d316f56047951fdaecd46d2dafbaaaf2c Mon Sep 17 00:00:00 2001
From: enmande <3836813+enmande@users.noreply.github.com>
Date: Wed, 12 Aug 2026 15:17:13 -0400
Subject: [PATCH 5/7] test(vault-banners): Remove emailVerified mocks.
---
.../vault-banners/services/vault-banners.service.spec.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts
index eb1e3de74eac..28167c388d9b 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts
@@ -20,14 +20,12 @@ describe("VaultBannersService", () => {
const hasPremiumFromAnySource$ = new BehaviorSubject(false);
const userId = Utils.newGuid() as UserId;
const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
- const getEmailVerified = jest.fn().mockResolvedValue(true);
const lastSync$ = new BehaviorSubject(null);
const pendingAuthRequests$ = new BehaviorSubject>([]);
beforeEach(() => {
lastSync$.next(new Date("2024-05-14"));
isSelfHost.mockClear();
- getEmailVerified.mockClear().mockResolvedValue(true);
TestBed.configureTestingModule({
providers: [
From 80deeaf78a6fefd66818149f689de078b2a176cd Mon Sep 17 00:00:00 2001
From: enmande <3836813+enmande@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:39:34 -0400
Subject: [PATCH 6/7] feat(verify-email): Improve badge reactivity and surface
"already verified" server error as a localized info message.
---
.../settings/account/profile.component.html | 2 +-
.../account/profile.component.spec.ts | 41 ++++++++++++++++---
.../settings/account/profile.component.ts | 36 +++++++++++++---
apps/web/src/locales/en/messages.json | 3 ++
4 files changed, 70 insertions(+), 12 deletions(-)
diff --git a/apps/web/src/app/auth/settings/account/profile.component.html b/apps/web/src/app/auth/settings/account/profile.component.html
index 205823386df5..937fbfd28dff 100644
--- a/apps/web/src/app/auth/settings/account/profile.component.html
+++ b/apps/web/src/app/auth/settings/account/profile.component.html
@@ -19,7 +19,7 @@
{{ "email" | i18n }}
- @if (profile.emailVerified) {
+ @if (emailVerified()) {
{
beforeEach(async () => {
apiService = mock();
toastService = mock();
- accountService = mockAccountServiceWith(userId);
+ accountService = mockAccountServiceWith(userId, { emailVerified: false });
+ // The fake doesn't wire setAccountEmailVerified back into activeAccount$ like its other
+ // setters do; patch it here so these tests can observe the component's reactive updates.
+ jest
+ .spyOn(accountService, "setAccountEmailVerified")
+ .mockImplementation(async (id, emailVerified) => {
+ accountService.activeAccountSubject.next({
+ id,
+ ...mockAccountInfoWith({ emailVerified }),
+ });
+ });
apiService.getProfile.mockResolvedValue(buildProfile(false));
@@ -64,7 +78,7 @@ describe("ProfileComponent", () => {
});
describe("email verification indicator", () => {
- it("shows the verified badge when the email is verified", async () => {
+ it("shows the verified badge when the profile fetch reports the email is verified", async () => {
apiService.getProfile.mockResolvedValue(buildProfile(true));
await component.ngOnInit();
@@ -79,9 +93,7 @@ describe("ProfileComponent", () => {
expect(verifyButton).toBeNull();
});
- it("shows the verify email button when the email is not verified", async () => {
- apiService.getProfile.mockResolvedValue(buildProfile(false));
-
+ it("shows the verify email button when the profile fetch indicates the email is not verified", async () => {
await component.ngOnInit();
fixture.detectChanges();
@@ -93,6 +105,23 @@ describe("ProfileComponent", () => {
expect(badge).toBeNull();
expect(verifyButton).not.toBeNull();
});
+
+ it("refreshes AccountService from the profile fetch on init, even if the cached account state is stale", async () => {
+ // Simulate a sync-gated AccountService cache that never picked up the verification
+ // (the server doesn't bump the account revision date on email confirmation).
+ accountService.activeAccountSubject.next({
+ id: userId,
+ ...mockAccountInfoWith({ emailVerified: false }),
+ });
+ apiService.getProfile.mockResolvedValue(buildProfile(true));
+
+ await component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(accountService.setAccountEmailVerified).toHaveBeenCalledWith(userId, true);
+ const badge = fixture.debugElement.nativeElement.querySelector("[bitbadge]");
+ expect(badge).not.toBeNull();
+ });
});
describe("verifyEmail", () => {
diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts
index 111007b1e9ef..d370a22c58c7 100644
--- a/apps/web/src/app/auth/settings/account/profile.component.ts
+++ b/apps/web/src/app/auth/settings/account/profile.component.ts
@@ -8,6 +8,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UpdateProfileRequest } from "@bitwarden/common/auth/models/request/update-profile.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -41,6 +42,10 @@ export class ProfileComponent implements OnInit {
this.accountService.activeAccount$.pipe(map((account) => account?.email ?? "")),
);
+ protected readonly emailVerified = toSignal(
+ this.accountService.activeAccount$.pipe(map((account) => account?.emailVerified ?? false)),
+ );
+
// Live value of the name field so the avatar initials update as the user types.
private readonly enteredName = toSignal(this.formGroup.controls.name.valueChanges, {
initialValue: "",
@@ -77,6 +82,12 @@ export class ProfileComponent implements OnInit {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.fingerprintMaterial.set(userId);
+ // The server doesn't bump the account revision date on email confirmation, so
+ // syncs that filter on an updated revision date won't update or emit here for email
+ // verification performed on the same tab. Refresh it from the profile fetch above
+ // (loaded with the component) instead of relying on AccountService's last-synced value.
+ await this.accountService.setAccountEmailVerified(userId, profile.emailVerified);
+
const publicKey = (await firstValueFrom(
this.keyService.userPublicKey$(userId),
)) as UserPublicKey;
@@ -116,10 +127,25 @@ export class ProfileComponent implements OnInit {
};
protected readonly verifyEmail = async () => {
- await this.apiService.postAccountVerifyEmail();
- this.toastService.showToast({
- variant: "success",
- message: this.i18nService.t("checkInboxForVerification"),
- });
+ try {
+ await this.apiService.postAccountVerifyEmail();
+ this.toastService.showToast({
+ variant: "success",
+ message: this.i18nService.t("checkInboxForVerification"),
+ });
+ } catch (error: unknown) {
+ if (error instanceof ErrorResponse && error.message.includes("Email already verified.")) {
+ // The server rejects re-verification once the email is confirmed; this can happen if the
+ // badge hasn't refreshed yet on the initiating tab after verifying in another tab/session.
+ const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
+ await this.accountService.setAccountEmailVerified(userId, true);
+ this.toastService.showToast({
+ variant: "info",
+ message: this.i18nService.t("emailAlreadyVerified"),
+ });
+ return;
+ }
+ throw error;
+ }
};
}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index b2c2498dab06..8e7d97f656b0 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -6417,6 +6417,9 @@
"emailVerifiedV2": {
"message": "Email verified"
},
+ "emailAlreadyVerified": {
+ "message": "Email already verified"
+ },
"emailVerifiedFailed": {
"message": "Unable to verify your email. Try sending a new verification email."
},
From b86c8c511a8cd372007b1c6c7b921895cbaf3f8c Mon Sep 17 00:00:00 2001
From: Dave <3836813+enmande@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:47:13 -0400
Subject: [PATCH 7/7] Update
apps/web/src/app/auth/settings/account/profile.component.ts
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
---
apps/web/src/app/auth/settings/account/profile.component.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts
index d370a22c58c7..769777185ab4 100644
--- a/apps/web/src/app/auth/settings/account/profile.component.ts
+++ b/apps/web/src/app/auth/settings/account/profile.component.ts
@@ -134,7 +134,7 @@ export class ProfileComponent implements OnInit {
message: this.i18nService.t("checkInboxForVerification"),
});
} catch (error: unknown) {
- if (error instanceof ErrorResponse && error.message.includes("Email already verified.")) {
+ if (error instanceof ErrorResponse && error.message?.includes("Email already verified.")) {
// The server rejects re-verification once the email is confirmed; this can happen if the
// badge hasn't refreshed yet on the initiating tab after verifying in another tab/session.
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));