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$));