1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 18:23:31 +00:00

[PM-28264] Consolidate and update the UI for key connector migration/confirmation (#17642)

* Consolidate the RemovePasswordComponent

* Add getting confirmation details for confirm key connector

* Add missing message
This commit is contained in:
Thomas Avery
2025-12-10 15:24:20 -06:00
committed by GitHub
parent 93640e65e3
commit fe4895d97e
30 changed files with 496 additions and 206 deletions

View File

@@ -8,17 +8,34 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
} @else {
<div class="tw-mb-4">
<p class="tw-mb-1 tw-text-sm tw-font-medium">{{ "keyConnectorDomain" | i18n }}:</p>
<p class="tw-text-muted tw-break-all">{{ keyConnectorUrl }}</p>
</div>
@if (organizationName) {
<p>{{ "confirmKeyConnectorOrganizationUserDescription" | i18n }}</p>
<p class="tw-mb-0 tw-font-bold">{{ "organization" | i18n }}</p>
<p class="tw-mb-6">{{ organizationName }}</p>
} @else {
<p>{{ "verifyYourDomainDescription" | i18n }}</p>
}
<p class="tw-mb-0 tw-font-bold tw-inline-flex tw-items-center">
{{ "domain" | i18n }}
<button
type="button"
[label]="'keyConnectorDomainTooltip' | i18n"
tooltipPosition="above-center"
bitIconButton="bwi-info-circle"
size="small"
></button>
</p>
<p class="tw-mb-6 tw-font-mono">{{ keyConnectorHostName }}</p>
<div class="tw-flex tw-flex-col tw-gap-2">
<button bitButton type="button" buttonType="primary" [bitAction]="confirm" [block]="true">
{{ "confirm" | i18n }}
{{ "continueWithLogIn" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" [bitAction]="cancel" [block]="true">
{{ "cancel" | i18n }}
{{ "doNotContinue" | i18n }}
</button>
</div>
}

View File

@@ -2,13 +2,17 @@ import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation";
import { KeyConnectorConfirmationDetailsResponse } from "@bitwarden/common/key-management/key-connector/models/response/key-connector-confirmation-details.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import { ConfirmKeyConnectorDomainComponent } from "./confirm-key-connector-domain.component";
@@ -16,8 +20,10 @@ describe("ConfirmKeyConnectorDomainComponent", () => {
let component: ConfirmKeyConnectorDomainComponent;
const userId = "test-user-id" as UserId;
const expectedHostName = "key-connector-url.com";
const confirmation: KeyConnectorDomainConfirmation = {
keyConnectorUrl: "https://key-connector-url.com",
organizationSsoIdentifier: "org-sso-identifier",
};
const mockRouter = mock<Router>();
@@ -25,6 +31,10 @@ describe("ConfirmKeyConnectorDomainComponent", () => {
const mockKeyConnectorService = mock<KeyConnectorService>();
const mockLogService = mock<LogService>();
const mockMessagingService = mock<MessagingService>();
const mockKeyConnectorApiService = mock<KeyConnectorApiService>();
const mockToastService = mock<ToastService>();
const mockI18nService = mock<I18nService>();
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
let mockAccountService = mockAccountServiceWith(userId);
const onBeforeNavigation = jest.fn();
@@ -33,6 +43,8 @@ describe("ConfirmKeyConnectorDomainComponent", () => {
mockAccountService = mockAccountServiceWith(userId);
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
component = new ConfirmKeyConnectorDomainComponent(
mockRouter,
mockLogService,
@@ -40,6 +52,10 @@ describe("ConfirmKeyConnectorDomainComponent", () => {
mockMessagingService,
mockSyncService,
mockAccountService,
mockKeyConnectorApiService,
mockToastService,
mockI18nService,
mockAnonLayoutWrapperDataService,
);
jest.spyOn(component, "onBeforeNavigation").mockImplementation(onBeforeNavigation);
@@ -67,17 +83,41 @@ describe("ConfirmKeyConnectorDomainComponent", () => {
expect(component.loading).toEqual(true);
});
it("sets organization name to undefined when getOrganizationName throws error", async () => {
mockKeyConnectorApiService.getConfirmationDetails.mockRejectedValue(new Error("API error"));
await component.ngOnInit();
expect(component.organizationName).toBeUndefined();
expect(component.userId).toEqual(userId);
expect(component.keyConnectorUrl).toEqual(confirmation.keyConnectorUrl);
expect(component.keyConnectorHostName).toEqual(expectedHostName);
expect(component.loading).toEqual(false);
expect(mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData).toHaveBeenCalledWith({
pageTitle: { key: "verifyYourDomainToLogin" },
});
});
it("should set component properties correctly", async () => {
const expectedOrgName = "Test Organization";
mockKeyConnectorApiService.getConfirmationDetails.mockResolvedValue({
organizationName: expectedOrgName,
} as KeyConnectorConfirmationDetailsResponse);
await component.ngOnInit();
expect(component.userId).toEqual(userId);
expect(component.organizationName).toEqual(expectedOrgName);
expect(component.keyConnectorUrl).toEqual(confirmation.keyConnectorUrl);
expect(component.keyConnectorHostName).toEqual(expectedHostName);
expect(component.loading).toEqual(false);
});
});
describe("confirm", () => {
it("should call keyConnectorService.convertNewSsoUserToKeyConnector with full sync and navigation to home page", async () => {
it("calls domain verified toast when organization name is not set", async () => {
mockKeyConnectorApiService.getConfirmationDetails.mockRejectedValue(new Error("API error"));
await component.ngOnInit();
await component.confirm();
@@ -94,6 +134,43 @@ describe("ConfirmKeyConnectorDomainComponent", () => {
expect(mockSyncService.fullSync.mock.invocationCallOrder[0]).toBeLessThan(
mockMessagingService.send.mock.invocationCallOrder[0],
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "domainVerified-used-i18n",
});
expect(mockMessagingService.send.mock.invocationCallOrder[0]).toBeLessThan(
onBeforeNavigation.mock.invocationCallOrder[0],
);
expect(onBeforeNavigation.mock.invocationCallOrder[0]).toBeLessThan(
mockRouter.navigate.mock.invocationCallOrder[0],
);
});
it("should call keyConnectorService.convertNewSsoUserToKeyConnector with full sync and navigation to home page", async () => {
mockKeyConnectorApiService.getConfirmationDetails.mockResolvedValue({
organizationName: "Test Org Name",
} as KeyConnectorConfirmationDetailsResponse);
await component.ngOnInit();
await component.confirm();
expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId);
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
expect(mockMessagingService.send).toHaveBeenCalledWith("loggedIn");
expect(onBeforeNavigation).toHaveBeenCalled();
expect(
mockKeyConnectorService.convertNewSsoUserToKeyConnector.mock.invocationCallOrder[0],
).toBeLessThan(mockSyncService.fullSync.mock.invocationCallOrder[0]);
expect(mockSyncService.fullSync.mock.invocationCallOrder[0]).toBeLessThan(
mockMessagingService.send.mock.invocationCallOrder[0],
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "organizationVerified-used-i18n",
});
expect(mockMessagingService.send.mock.invocationCallOrder[0]).toBeLessThan(
onBeforeNavigation.mock.invocationCallOrder[0],
);

View File

@@ -5,12 +5,21 @@ import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { BitActionDirective, ButtonModule } from "@bitwarden/components";
import {
AnonLayoutWrapperDataService,
BitActionDirective,
ButtonModule,
IconButtonModule,
ToastService,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -19,11 +28,13 @@ import { I18nPipe } from "@bitwarden/ui-common";
selector: "confirm-key-connector-domain",
templateUrl: "confirm-key-connector-domain.component.html",
standalone: true,
imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective],
imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective, IconButtonModule],
})
export class ConfirmKeyConnectorDomainComponent implements OnInit {
loading = true;
keyConnectorUrl!: string;
keyConnectorHostName!: string;
organizationName: string | undefined;
userId!: UserId;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -37,6 +48,10 @@ export class ConfirmKeyConnectorDomainComponent implements OnInit {
private messagingService: MessagingService,
private syncService: SyncService,
private accountService: AccountService,
private keyConnectorApiService: KeyConnectorApiService,
private toastService: ToastService,
private i18nService: I18nService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
) {}
async ngOnInit() {
@@ -57,14 +72,36 @@ export class ConfirmKeyConnectorDomainComponent implements OnInit {
return;
}
this.keyConnectorUrl = confirmation.keyConnectorUrl;
this.organizationName = await this.getOrganizationName(confirmation.organizationSsoIdentifier);
// PM-29133 Remove during cleanup.
if (this.organizationName == undefined) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "verifyYourDomainToLogin" },
});
}
this.keyConnectorUrl = confirmation.keyConnectorUrl;
this.keyConnectorHostName = Utils.getHostname(confirmation.keyConnectorUrl);
this.loading = false;
}
confirm = async () => {
await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId);
if (this.organizationName) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationVerified"),
});
} else {
// PM-29133 Remove during cleanup.
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("domainVerified"),
});
}
await this.syncService.fullSync(true);
this.messagingService.send("loggedIn");
@@ -77,4 +114,22 @@ export class ConfirmKeyConnectorDomainComponent implements OnInit {
cancel = async () => {
this.messagingService.send("logout");
};
private async getOrganizationName(
organizationSsoIdentifier: string,
): Promise<string | undefined> {
try {
const details =
await this.keyConnectorApiService.getConfirmationDetails(organizationSsoIdentifier);
return details.organizationName;
} catch (error) {
// PM-29133 Remove during cleanup.
// Old self hosted servers may not have this endpoint yet. On error log a warning and continue without organization name.
this.logService.warning(
`[ConfirmKeyConnectorDomainComponent] Unable to get key connector confirmation details for organizationSsoIdentifier ${organizationSsoIdentifier}:`,
error,
);
return undefined;
}
}
}

View File

@@ -0,0 +1,35 @@
@if (loading) {
<div class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
} @else {
<p>{{ "removeMasterPasswordForOrgUserKeyConnector" | i18n }}</p>
<p class="tw-mb-0 tw-font-bold">{{ "organization" | i18n }}</p>
<p class="tw-mb-6">{{ organization.name }}</p>
<p class="tw-mb-0 tw-font-bold tw-inline-flex tw-items-center">
{{ "domain" | i18n }}
<button
type="button"
[label]="'keyConnectorDomainTooltip' | i18n"
tooltipPosition="above-center"
bitIconButton="bwi-info-circle"
size="small"
></button>
</p>
<p class="tw-mb-6 tw-font-mono">{{ keyConnectorHostName }}</p>
<div class="tw-flex tw-flex-col tw-gap-2">
<button bitButton type="button" buttonType="primary" [block]="true" [bitAction]="convert">
{{ "continueWithLogIn" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" [block]="true" [bitAction]="leave">
{{ "doNotContinue" | i18n }}
</button>
</div>
}

View File

@@ -19,6 +19,7 @@ describe("RemovePasswordComponent", () => {
let component: RemovePasswordComponent;
const userId = "test-user-id" as UserId;
const expectedHostName = "key-connector-url.com";
const organization = {
id: "test-organization-id",
name: "test-organization-name",
@@ -62,6 +63,7 @@ describe("RemovePasswordComponent", () => {
expect(component["activeUserId"]).toBe("test-user-id");
expect(component.organization).toEqual(organization);
expect(component.loading).toEqual(false);
expect(component.keyConnectorHostName).toBe(expectedHostName);
expect(mockKeyConnectorService.getManagingOrganization).toHaveBeenCalledWith(userId);
expect(mockSyncService.fullSync).toHaveBeenCalledWith(false);

View File

@@ -1,4 +1,5 @@
import { Directive, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
@@ -9,17 +10,33 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import {
DialogService,
ToastService,
ButtonModule,
BitActionDirective,
IconButtonModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Directive()
// 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: "km-ui-remove-password",
templateUrl: "remove-password.component.html",
standalone: true,
imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective, IconButtonModule],
})
export class RemovePasswordComponent implements OnInit {
continuing = false;
leaving = false;
loading = true;
organization!: Organization;
keyConnectorHostName!: string;
private activeUserId!: UserId;
constructor(
@@ -55,6 +72,7 @@ export class RemovePasswordComponent implements OnInit {
await this.router.navigate(["/"]);
return;
}
this.keyConnectorHostName = Utils.getHostname(this.organization.keyConnectorUrl);
this.loading = false;
}
@@ -73,7 +91,7 @@ export class RemovePasswordComponent implements OnInit {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("removedMasterPassword"),
message: this.i18nService.t("organizationVerified"),
});
await this.router.navigate(["/"]);
@@ -86,9 +104,11 @@ export class RemovePasswordComponent implements OnInit {
leave = async () => {
const confirmed = await this.dialogService.openSimpleDialog({
title: this.organization.name,
content: { key: "leaveOrganizationConfirmation" },
title: { key: "leaveOrganization" },
content: { key: "leaveOrganizationContent" },
type: "warning",
acceptButtonText: { key: "leaveNow" },
cancelButtonText: { key: "cancel" },
});
if (!confirmed) {