Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/web/src/app/auth/settings/account/profile.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input bitInput [value]="email()" readonly />
@if (emailVerified()) {
<span
bitBadge
variant="success"
slot="inline-end"
class="tw-shrink-0 tw-whitespace-nowrap"
>
{{ "verified" | i18n }}
</span>
} @else {
<button
id="profile_button_verifyEmail"
type="button"
buttonType="secondary"
size="small"
startIcon="bwi-envelope"
bitButton
bitFormButton
appStopClick
appStopProp
slot="inline-end"
class="tw-shrink-0 tw-whitespace-nowrap"
[bitAction]="verifyEmail"
>
{{ "verifyEmail" | i18n }}
</button>
}
</bit-form-field>
</div>
<div class="tw-col-span-12 @3xl:tw-col-span-6 tw-row-start-1 @3xl:tw-row-start-auto">
Expand Down
138 changes: 138 additions & 0 deletions apps/web/src/app/auth/settings/account/profile.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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,
mockAccountInfoWith,
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<ProfileComponent>;
let apiService: ReturnType<typeof mock<ApiService>>;
let toastService: ReturnType<typeof mock<ToastService>>;
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<ApiService>();
toastService = mock<ToastService>();
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));

await TestBed.configureTestingModule({
imports: [ProfileComponent],
providers: [
{ provide: ApiService, useValue: apiService },
{
provide: OrganizationService,
useValue: mock<OrganizationService>({ organizations$: () => of([]) }),
},
{ provide: AccountService, useValue: accountService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: ToastService, useValue: toastService },
{ provide: KeyService, useValue: mock<KeyService>({ userPublicKey$: () => of(null) }) },
{ provide: AvatarService, useValue: mock<AvatarService>({ avatarColor$: of(null) }) },
],
}).compileComponents();

fixture = TestBed.createComponent(ProfileComponent);
component = fixture.componentInstance;
});

describe("email verification indicator", () => {
it("shows the verified badge when the profile fetch reports 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 profile fetch indicates the email is not verified", async () => {
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();
});

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", () => {
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",
});
});
});
});
34 changes: 34 additions & 0 deletions apps/web/src/app/auth/settings/account/profile.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -114,4 +125,27 @@ export class ProfileComponent implements OnInit {
message: this.i18nService.t("accountUpdated"),
});
};

protected readonly verifyEmail = async () => {
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;
}
};
}
13 changes: 0 additions & 13 deletions apps/web/src/app/auth/settings/verify-email.component.html

This file was deleted.

67 changes: 0 additions & 67 deletions apps/web/src/app/auth/settings/verify-email.component.ts

This file was deleted.

Loading
Loading