mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 00:23:17 +00:00
Merge branch 'main' into billing/pm-29602/build-upgrade-dialogs
This commit is contained in:
@@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { ReplaySubject } from "rxjs";
|
||||
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentService,
|
||||
@@ -46,23 +45,16 @@ describe("PeopleTableDataSource", () => {
|
||||
isCloud: () => false,
|
||||
} as Environment);
|
||||
|
||||
const mockConfigService = {
|
||||
getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()),
|
||||
} as any;
|
||||
|
||||
const mockEnvironmentService = {
|
||||
environment$: environmentSubject.asObservable(),
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
],
|
||||
providers: [{ provide: EnvironmentService, useValue: mockEnvironmentService }],
|
||||
});
|
||||
|
||||
dataSource = TestBed.runInInjectionContext(
|
||||
() => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService),
|
||||
() => new TestPeopleTableDataSource(mockEnvironmentService),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { computed, Signal } from "@angular/core";
|
||||
import { Signal } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { Observable, Subject, map } from "rxjs";
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
ProviderUserStatusType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
|
||||
@@ -27,8 +25,7 @@ export type ProviderUser = ProviderUserUserDetailsResponse;
|
||||
export const MaxCheckedCount = 500;
|
||||
|
||||
/**
|
||||
* Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud
|
||||
* feature flag is enabled on cloud environments.
|
||||
* Maximum for bulk reinvite limit in cloud environments.
|
||||
*/
|
||||
export const CloudBulkReinviteLimit = 8000;
|
||||
|
||||
@@ -78,18 +75,15 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
confirmedUserCount: number;
|
||||
revokedUserCount: number;
|
||||
|
||||
/** True when increased bulk limit feature is enabled (feature flag + cloud environment) */
|
||||
/** True when increased bulk limit feature is enabled (cloud environment) */
|
||||
readonly isIncreasedBulkLimitEnabled: Signal<boolean>;
|
||||
|
||||
constructor(configService: ConfigService, environmentService: EnvironmentService) {
|
||||
constructor(environmentService: EnvironmentService) {
|
||||
super();
|
||||
|
||||
const featureFlagEnabled = toSignal(
|
||||
configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
|
||||
this.isIncreasedBulkLimitEnabled = toSignal(
|
||||
environmentService.environment$.pipe(map((env) => env.isCloud())),
|
||||
);
|
||||
const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud())));
|
||||
|
||||
this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud());
|
||||
}
|
||||
|
||||
override set data(data: T[]) {
|
||||
@@ -224,12 +218,9 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag.
|
||||
* Returns checked users in visible order, optionally limited to the specified count.
|
||||
*
|
||||
* When the feature flag is enabled: Returns checked users in visible order, limited to the specified count.
|
||||
* When the feature flag is disabled: Returns all checked users without applying any limit.
|
||||
*
|
||||
* @param limit The maximum number of users to return (only applied when feature flag is enabled)
|
||||
* @param limit The maximum number of users to return
|
||||
* @returns The checked users array
|
||||
*/
|
||||
getCheckedUsersWithLimit(limit: number): T[] {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { BannerModule, IconModule } from "@bitwarden/components";
|
||||
import { BannerModule, SvgModule } from "@bitwarden/components";
|
||||
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
@@ -47,7 +47,7 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module";
|
||||
RouterModule,
|
||||
JslibModule,
|
||||
WebLayoutModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
OrgSwitcherComponent,
|
||||
BannerModule,
|
||||
TaxIdWarningComponent,
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { DIALOG_DATA, DialogService } from "@bitwarden/components";
|
||||
|
||||
import { BulkUserDetails } from "./bulk-status.component";
|
||||
@@ -34,10 +45,15 @@ export class BulkRestoreRevokeComponent {
|
||||
error: string;
|
||||
showNoMasterPasswordWarning = false;
|
||||
nonCompliantMembers: boolean = false;
|
||||
organization$: Observable<Organization>;
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private accountService: AccountService,
|
||||
private organizationService: OrganizationService,
|
||||
private configService: ConfigService,
|
||||
@Inject(DIALOG_DATA) protected data: BulkRestoreDialogParams,
|
||||
) {
|
||||
this.isRevoking = data.isRevoking;
|
||||
@@ -46,6 +62,18 @@ export class BulkRestoreRevokeComponent {
|
||||
this.showNoMasterPasswordWarning = this.users.some(
|
||||
(u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false,
|
||||
);
|
||||
|
||||
this.organization$ = accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => organizationService.organizations$(userId)),
|
||||
getById(this.organizationId),
|
||||
map((organization) => {
|
||||
if (organization == null) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
get bulkTitle() {
|
||||
@@ -83,9 +111,22 @@ export class BulkRestoreRevokeComponent {
|
||||
userIds,
|
||||
);
|
||||
} else {
|
||||
return await this.organizationUserApiService.restoreManyOrganizationUsers(
|
||||
this.organizationId,
|
||||
userIds,
|
||||
return await firstValueFrom(
|
||||
combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore),
|
||||
this.organization$,
|
||||
]).pipe(
|
||||
switchMap(([enabled, organization]) => {
|
||||
if (enabled) {
|
||||
return this.organizationUserService.bulkRestoreUsers(organization, userIds);
|
||||
} else {
|
||||
return this.organizationUserApiService.restoreManyOrganizationUsers(
|
||||
this.organizationId,
|
||||
userIds,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,9 @@ import {
|
||||
import {
|
||||
CollectionAdminService,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
@@ -36,8 +34,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
@@ -197,14 +197,19 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
private deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
) {
|
||||
this.organization$ = accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
organizationService
|
||||
.organizations$(account?.id)
|
||||
.pipe(getOrganizationById(this.params.organizationId))
|
||||
.pipe(shareReplay({ refCount: true, bufferSize: 1 })),
|
||||
),
|
||||
getUserId,
|
||||
switchMap((userId) => organizationService.organizations$(userId)),
|
||||
getById(this.params.organizationId),
|
||||
map((organization) => {
|
||||
if (organization == null) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return organization;
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
let userDetails$;
|
||||
@@ -633,9 +638,26 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.organizationUserApiService.restoreOrganizationUser(
|
||||
this.params.organizationId,
|
||||
this.params.organizationUserId,
|
||||
await firstValueFrom(
|
||||
combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore),
|
||||
this.organization$,
|
||||
this.editParams$,
|
||||
]).pipe(
|
||||
switchMap(([enabled, organization, params]) => {
|
||||
if (enabled) {
|
||||
return this.organizationUserService.restoreUser(
|
||||
organization,
|
||||
params.organizationUserId,
|
||||
);
|
||||
} else {
|
||||
return this.organizationUserApiService.restoreOrganizationUser(
|
||||
params.organizationId,
|
||||
params.organizationUserId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -124,7 +123,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
private memberExportService: MemberExportService,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
@@ -139,7 +137,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
toastService,
|
||||
);
|
||||
|
||||
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
|
||||
this.dataSource = new MembersTableDataSource(this.environmentService);
|
||||
|
||||
const organization$ = this.route.params.pipe(
|
||||
concatMap((params) =>
|
||||
|
||||
@@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -100,7 +99,6 @@ export class vNextMembersComponent {
|
||||
private policyService = inject(PolicyService);
|
||||
private policyApiService = inject(PolicyApiServiceAbstraction);
|
||||
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
|
||||
private configService = inject(ConfigService);
|
||||
private environmentService = inject(EnvironmentService);
|
||||
private memberExportService = inject(MemberExportService);
|
||||
|
||||
@@ -114,7 +112,7 @@ export class vNextMembersComponent {
|
||||
protected statusToggle = new BehaviorSubject<OrganizationUserStatusType | undefined>(undefined);
|
||||
|
||||
protected readonly dataSource: Signal<MembersTableDataSource> = signal(
|
||||
new MembersTableDataSource(this.configService, this.environmentService),
|
||||
new MembersTableDataSource(this.environmentService),
|
||||
);
|
||||
protected readonly organization: Signal<Organization | undefined>;
|
||||
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
|
||||
@@ -389,7 +387,7 @@ export class vNextMembersComponent {
|
||||
// Capture the original count BEFORE enforcing the limit
|
||||
const originalInvitedCount = allInvitedUsers.length;
|
||||
|
||||
// When feature flag is enabled, limit invited users and uncheck the excess
|
||||
// In cloud environments, limit invited users and uncheck the excess
|
||||
let filteredUsers: OrganizationUserView[];
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
filteredUsers = this.dataSource().limitAndUncheckExcess(
|
||||
@@ -418,7 +416,7 @@ export class vNextMembersComponent {
|
||||
this.validationService.showError(result.failed);
|
||||
}
|
||||
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
// In cloud environments, show toast instead of dialog
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = filteredUsers.length;
|
||||
@@ -441,7 +439,7 @@ export class vNextMembersComponent {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
// In self-hosted environments, show legacy dialog
|
||||
await this.memberDialogManager.openBulkStatusDialog(
|
||||
users,
|
||||
filteredUsers,
|
||||
@@ -514,7 +512,7 @@ export class vNextMembersComponent {
|
||||
if (result.error != null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t(result.error),
|
||||
message: result.error,
|
||||
});
|
||||
this.logService.error(result.error);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { of, throwError } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
@@ -178,25 +178,64 @@ describe("MemberActionsService", () => {
|
||||
});
|
||||
|
||||
describe("restoreUser", () => {
|
||||
it("should successfully restore a user", async () => {
|
||||
organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined);
|
||||
describe("when feature flag is enabled", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
});
|
||||
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
it("should call organizationUserService.restoreUser", async () => {
|
||||
organizationUserService.restoreUser.mockReturnValue(of(undefined));
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdToManage,
|
||||
);
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(organizationUserService.restoreUser).toHaveBeenCalledWith(
|
||||
mockOrganization,
|
||||
userIdToManage,
|
||||
);
|
||||
expect(organizationUserApiService.restoreOrganizationUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors from organizationUserService.restoreUser", async () => {
|
||||
const errorMessage = "Restore failed";
|
||||
organizationUserService.restoreUser.mockReturnValue(
|
||||
throwError(() => new Error(errorMessage)),
|
||||
);
|
||||
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(result).toEqual({ success: false, error: errorMessage });
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle restore errors", async () => {
|
||||
const errorMessage = "Restore failed";
|
||||
organizationUserApiService.restoreOrganizationUser.mockRejectedValue(new Error(errorMessage));
|
||||
describe("when feature flag is disabled", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
});
|
||||
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
it("should call organizationUserApiService.restoreOrganizationUser", async () => {
|
||||
organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined);
|
||||
|
||||
expect(result).toEqual({ success: false, error: errorMessage });
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdToManage,
|
||||
);
|
||||
expect(organizationUserService.restoreUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors", async () => {
|
||||
const errorMessage = "Restore failed";
|
||||
organizationUserApiService.restoreOrganizationUser.mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
|
||||
const result = await service.restoreUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(result).toEqual({ success: false, error: errorMessage });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,308 +318,247 @@ describe("MemberActionsService", () => {
|
||||
});
|
||||
|
||||
describe("bulkReinvite", () => {
|
||||
const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId];
|
||||
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
|
||||
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
|
||||
const mockResponse = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
describe("when feature flag is false", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
});
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
|
||||
|
||||
it("should successfully reinvite multiple users", async () => {
|
||||
const mockResponse = new ListResponse(
|
||||
{
|
||||
data: userIds.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIds);
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful).toEqual(mockResponse);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIds,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle bulk reinvite errors", async () => {
|
||||
const errorMessage = "Bulk reinvite failed";
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIds);
|
||||
|
||||
expect(result.successful).toBeUndefined();
|
||||
expect(result.failed).toHaveLength(3);
|
||||
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
|
||||
});
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdsBatch,
|
||||
);
|
||||
});
|
||||
|
||||
describe("when feature flag is true (batching behavior)", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
});
|
||||
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
|
||||
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
|
||||
const mockResponse = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
userIdsBatch,
|
||||
);
|
||||
});
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(totalUsers);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
organizationId,
|
||||
userIdsBatch.slice(0, REQUESTS_PER_BATCH),
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
organizationId,
|
||||
userIdsBatch.slice(REQUESTS_PER_BATCH),
|
||||
);
|
||||
});
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
it("should aggregate results across multiple successful batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(totalUsers);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
organizationId,
|
||||
userIdsBatch.slice(0, REQUESTS_PER_BATCH),
|
||||
);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
organizationId,
|
||||
userIdsBatch.slice(REQUESTS_PER_BATCH),
|
||||
);
|
||||
});
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
it("should aggregate results across multiple successful batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(totalUsers);
|
||||
expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data);
|
||||
expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
it("should handle mixed individual errors across multiple batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 4;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({
|
||||
id,
|
||||
error: index % 10 === 0 ? "Rate limit exceeded" : null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" },
|
||||
],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(totalUsers);
|
||||
expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(
|
||||
mockResponse1.data,
|
||||
);
|
||||
expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
it("should handle mixed individual errors across multiple batches", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 4;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({
|
||||
id,
|
||||
error: index % 10 === 0 ? "Rate limit exceeded" : null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
|
||||
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
|
||||
const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1;
|
||||
const expectedFailuresInBatch2 = 2;
|
||||
const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2;
|
||||
const expectedSuccesses = totalUsers - expectedTotalFailures;
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null },
|
||||
{ id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" },
|
||||
],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(expectedSuccesses);
|
||||
expect(result.failed).toHaveLength(expectedTotalFailures);
|
||||
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "User suspended")).toBe(true);
|
||||
});
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
it("should aggregate all failures when all batches fail", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const errorMessage = "All batches failed";
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
|
||||
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
|
||||
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
|
||||
const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1;
|
||||
const expectedFailuresInBatch2 = 2;
|
||||
const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2;
|
||||
const expectedSuccesses = totalUsers - expectedTotalFailures;
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(expectedSuccesses);
|
||||
expect(result.failed).toHaveLength(expectedTotalFailures);
|
||||
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
|
||||
expect(result.failed.some((f) => f.error === "User suspended")).toBe(true);
|
||||
});
|
||||
expect(result.successful).toBeUndefined();
|
||||
expect(result.failed).toHaveLength(totalUsers);
|
||||
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should aggregate all failures when all batches fail", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 100;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const errorMessage = "All batches failed";
|
||||
it("should handle empty data in batch response", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
|
||||
expect(result.successful).toBeUndefined();
|
||||
expect(result.failed).toHaveLength(totalUsers);
|
||||
expect(result.failed.every((f) => f.error === errorMessage)).toBe(true);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
});
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
it("should handle empty data in batch response", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH + 50;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
const mockResponse1 = new ListResponse(
|
||||
{
|
||||
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
const mockResponse2 = new ListResponse(
|
||||
{
|
||||
data: [],
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
it("should process batches sequentially in order", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH * 2;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const callOrder: number[] = [];
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
|
||||
async (orgId, ids) => {
|
||||
const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2;
|
||||
callOrder.push(batchIndex);
|
||||
|
||||
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
return new ListResponse(
|
||||
{
|
||||
data: ids.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.successful).toBeDefined();
|
||||
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
});
|
||||
await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
it("should process batches sequentially in order", async () => {
|
||||
const totalUsers = REQUESTS_PER_BATCH * 2;
|
||||
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
|
||||
const callOrder: number[] = [];
|
||||
|
||||
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
|
||||
async (orgId, ids) => {
|
||||
const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2;
|
||||
callOrder.push(batchIndex);
|
||||
|
||||
return new ListResponse(
|
||||
{
|
||||
data: ids.map((id) => ({
|
||||
id,
|
||||
error: null,
|
||||
})),
|
||||
continuationToken: null,
|
||||
},
|
||||
OrganizationUserBulkResponse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await service.bulkReinvite(mockOrganization, userIdsBatch);
|
||||
|
||||
expect(callOrder).toEqual([1, 2]);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
});
|
||||
expect(callOrder).toEqual([1, 2]);
|
||||
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { inject, Injectable, signal } from "@angular/core";
|
||||
import { lastValueFrom, firstValueFrom } from "rxjs";
|
||||
import { lastValueFrom, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
@@ -10,8 +10,8 @@ import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import {
|
||||
OrganizationUserType,
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
@@ -119,7 +119,21 @@ export class MemberActionsService {
|
||||
async restoreUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId);
|
||||
await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore).pipe(
|
||||
switchMap((enabled) => {
|
||||
if (enabled) {
|
||||
return this.organizationUserService.restoreUser(organization, userId);
|
||||
} else {
|
||||
return this.organizationUserApiService.restoreOrganizationUser(
|
||||
organization.id,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -175,18 +189,9 @@ export class MemberActionsService {
|
||||
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
|
||||
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
|
||||
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
|
||||
);
|
||||
if (increaseBulkReinviteLimitForCloud) {
|
||||
return await this.vNextBulkReinvite(organization, userIds);
|
||||
} else {
|
||||
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
|
||||
organization.id,
|
||||
userIds,
|
||||
);
|
||||
return { successful: result, failed: [] };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
|
||||
@@ -196,15 +201,6 @@ export class MemberActionsService {
|
||||
}
|
||||
}
|
||||
|
||||
async vNextBulkReinvite(
|
||||
organization: Organization,
|
||||
userIds: UserId[],
|
||||
): Promise<BulkActionResult> {
|
||||
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
|
||||
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
|
||||
);
|
||||
}
|
||||
|
||||
allowResetPassword(
|
||||
orgUser: OrganizationUserView,
|
||||
organization: Organization,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs";
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordDetailsResponse,
|
||||
OrganizationUserResetPasswordRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
@@ -13,6 +14,15 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models
|
||||
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordAuthenticationHash,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -21,7 +31,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfig, KdfType, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
|
||||
|
||||
@@ -39,6 +49,8 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
beforeAll(() => {
|
||||
keyService = mock<KeyService>();
|
||||
@@ -48,6 +60,8 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
organizationApiService = mock<OrganizationApiService>();
|
||||
i18nService = mock<I18nService>();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
sut = new OrganizationUserResetPasswordService(
|
||||
keyService,
|
||||
@@ -57,6 +71,8 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
organizationApiService,
|
||||
i18nService,
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,13 +145,23 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetMasterPassword", () => {
|
||||
/**
|
||||
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are
|
||||
* any imports/properties in the test setup above that are now un-used and can also be removed.
|
||||
*/
|
||||
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = false;
|
||||
|
||||
const mockNewMP = "new-password";
|
||||
const mockEmail = "test@example.com";
|
||||
const mockOrgUserId = "test-org-user-id";
|
||||
const mockOrgId = "test-org-id";
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
|
||||
);
|
||||
|
||||
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
|
||||
new OrganizationUserResetPasswordDetailsResponse({
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
@@ -185,6 +211,164 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
|
||||
// Mock sut method parameters
|
||||
const newMasterPassword = "new-master-password";
|
||||
const email = "user@example.com";
|
||||
const orgUserId = "org-user-id";
|
||||
const orgId = "org-id" as OrganizationId;
|
||||
|
||||
// Mock feature flag value
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = true;
|
||||
|
||||
// Mock method data
|
||||
let organizationUserResetPasswordDetailsResponse: OrganizationUserResetPasswordDetailsResponse;
|
||||
let salt: MasterPasswordSalt;
|
||||
let kdfConfig: KdfConfig;
|
||||
let authenticationData: MasterPasswordAuthenticationData;
|
||||
let unlockData: MasterPasswordUnlockData;
|
||||
let userKey: UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock feature flag value
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
|
||||
);
|
||||
|
||||
// Mock method data
|
||||
kdfConfig = DEFAULT_KDF_CONFIG;
|
||||
|
||||
organizationUserResetPasswordDetailsResponse =
|
||||
new OrganizationUserResetPasswordDetailsResponse({
|
||||
organizationUserId: orgUserId,
|
||||
kdf: kdfConfig.kdfType,
|
||||
kdfIterations: kdfConfig.iterations,
|
||||
resetPasswordKey: "test-reset-password-key",
|
||||
encryptedPrivateKey: "test-encrypted-private-key",
|
||||
});
|
||||
|
||||
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
|
||||
organizationUserResetPasswordDetailsResponse,
|
||||
);
|
||||
|
||||
const mockDecryptedOrgKeyBytes = new Uint8Array(64).fill(1);
|
||||
const mockDecryptedOrgKey = new SymmetricCryptoKey(mockDecryptedOrgKeyBytes) as OrgKey;
|
||||
|
||||
keyService.orgKeys$.mockReturnValue(
|
||||
of({ [orgId]: mockDecryptedOrgKey } as Record<OrganizationId, OrgKey>),
|
||||
);
|
||||
|
||||
const mockDecryptedPrivateKeyBytes = new Uint8Array(64).fill(2);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockDecryptedPrivateKeyBytes);
|
||||
|
||||
const mockDecryptedUserKeyBytes = new Uint8Array(64).fill(3);
|
||||
const mockUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes);
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockUserKey); // returns `SymmetricCryptoKey`
|
||||
userKey = mockUserKey as UserKey; // type cast to `UserKey` (see code implementation). Points to same object as mockUserKey.
|
||||
|
||||
salt = email as MasterPasswordSalt;
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(salt);
|
||||
|
||||
authenticationData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
|
||||
unlockData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
|
||||
});
|
||||
|
||||
it("should throw an error if the organizationUserResetPasswordDetailsResponse is nullish", async () => {
|
||||
// Arrange
|
||||
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should throw an error if the org key cannot be found", async () => {
|
||||
// Arrange
|
||||
keyService.orgKeys$.mockReturnValue(of({} as Record<OrganizationId, OrgKey>));
|
||||
|
||||
// Act
|
||||
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("No org key found");
|
||||
});
|
||||
|
||||
it("should throw an error if orgKeys$ returns null", async () => {
|
||||
// Arrange
|
||||
keyService.orgKeys$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
|
||||
// Act
|
||||
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
const request = OrganizationUserResetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.mock.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
userKey,
|
||||
);
|
||||
|
||||
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
orgUserId,
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the API method to reset the user's master password", async () => {
|
||||
// Act
|
||||
await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
|
||||
|
||||
// Assert
|
||||
const request = OrganizationUserResetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
);
|
||||
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledTimes(1);
|
||||
expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
orgUserId,
|
||||
request,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPublicKeys", () => {
|
||||
it("should return public keys for organizations that have reset password enrolled", async () => {
|
||||
const result = await sut.getPublicKeys("userId" as UserId);
|
||||
|
||||
@@ -12,11 +12,15 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -47,6 +51,8 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -140,6 +146,44 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
|
||||
? new PBKDF2KdfConfig(response.kdfIterations)
|
||||
: new Argon2KdfConfig(response.kdfIterations, response.kdfMemory, response.kdfParallelism);
|
||||
|
||||
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
|
||||
);
|
||||
|
||||
if (newApisWithInputPasswordFlagEnabled) {
|
||||
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
|
||||
|
||||
// Create authentication and unlock data
|
||||
const authenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
newMasterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
existingUserKey,
|
||||
);
|
||||
|
||||
// Create request
|
||||
const request = OrganizationUserResetPasswordRequest.newConstructor(
|
||||
authenticationData,
|
||||
unlockData,
|
||||
);
|
||||
|
||||
// Change user's password
|
||||
await this.organizationUserApiService.putOrganizationUserResetPassword(
|
||||
orgId,
|
||||
orgUserId,
|
||||
request,
|
||||
);
|
||||
|
||||
return; // EARLY RETURN for flagged code
|
||||
}
|
||||
|
||||
// Create new master key and hash new password
|
||||
const newMasterKey = await this.keyService.makeMasterKey(
|
||||
newMasterPassword,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Constructor } from "type-fest";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
|
||||
@@ -80,7 +80,7 @@ export abstract class BasePolicyEditDefinition {
|
||||
export abstract class BasePolicyEditComponent implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() policyResponse: PolicyResponse | undefined;
|
||||
@Input() policyResponse: PolicyStatusResponse | undefined;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() policy: BasePolicyEditDefinition | undefined;
|
||||
|
||||
@@ -5,3 +5,4 @@ export { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
export { AutoConfirmPolicy } from "./policy-edit-definitions";
|
||||
export { PolicyEditDialogResult } from "./policy-edit-dialog.component";
|
||||
export * from "./policy-edit-dialogs";
|
||||
export { PolicyOrderPipe } from "./pipes/policy-order.pipe";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { BasePolicyEditDefinition } from "../base-policy-edit.component";
|
||||
|
||||
/**
|
||||
* Order mapping for policies. Policies are ordered according to this mapping.
|
||||
* Policies not in this mapping will appear at the end, maintaining their relative order.
|
||||
*/
|
||||
const POLICY_ORDER_MAP = new Map<string, number>([
|
||||
["singleOrg", 1],
|
||||
["organizationDataOwnership", 2],
|
||||
["centralizeDataOwnership", 2],
|
||||
["masterPassPolicyTitle", 3],
|
||||
["accountRecoveryPolicy", 4],
|
||||
["requireSso", 5],
|
||||
["automaticAppLoginWithSSO", 6],
|
||||
["twoStepLoginPolicyTitle", 7],
|
||||
["blockClaimedDomainAccountCreation", 8],
|
||||
["sessionTimeoutPolicyTitle", 9],
|
||||
["removeUnlockWithPinPolicyTitle", 10],
|
||||
["passwordGenerator", 11],
|
||||
["uriMatchDetectionPolicy", 12],
|
||||
["activateAutofillPolicy", 13],
|
||||
["sendOptions", 14],
|
||||
["disableSend", 15],
|
||||
["restrictedItemTypePolicy", 16],
|
||||
["freeFamiliesSponsorship", 17],
|
||||
["disableExport", 18],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Default order for policies not in the mapping. This ensures unmapped policies
|
||||
* appear at the end while maintaining their relative order.
|
||||
*/
|
||||
const DEFAULT_ORDER = 999;
|
||||
|
||||
@Pipe({
|
||||
name: "policyOrder",
|
||||
standalone: true,
|
||||
})
|
||||
export class PolicyOrderPipe implements PipeTransform {
|
||||
transform(
|
||||
policies: readonly BasePolicyEditDefinition[] | null | undefined,
|
||||
): BasePolicyEditDefinition[] {
|
||||
if (policies == null || policies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedPolicies = [...policies];
|
||||
|
||||
sortedPolicies.sort((a, b) => {
|
||||
const orderA = POLICY_ORDER_MAP.get(a.name) ?? DEFAULT_ORDER;
|
||||
const orderB = POLICY_ORDER_MAP.get(b.name) ?? DEFAULT_ORDER;
|
||||
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
|
||||
const indexA = policies.indexOf(a);
|
||||
const indexB = policies.indexOf(b);
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
return sortedPolicies;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
} @else {
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
@for (p of policies$ | async; track $index) {
|
||||
@for (p of policies$ | async | policyOrder; track $index) {
|
||||
@if (p.display$(organization, configService) | async) {
|
||||
<tr bitRow>
|
||||
<td bitCell ngPreserveWhitespaces>
|
||||
|
||||
@@ -21,13 +21,14 @@ import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component";
|
||||
import { PolicyOrderPipe } from "./pipes/policy-order.pipe";
|
||||
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
|
||||
import { PolicyListService } from "./policy-list.service";
|
||||
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
|
||||
|
||||
@Component({
|
||||
templateUrl: "policies.component.html",
|
||||
imports: [SharedModule, HeaderModule],
|
||||
imports: [SharedModule, HeaderModule, PolicyOrderPipe],
|
||||
providers: [
|
||||
safeProvider({
|
||||
provide: PolicyListService,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<ng-template #step1>
|
||||
<div class="tw-flex tw-justify-center tw-mb-6">
|
||||
<bit-icon class="tw-w-[233px]" [icon]="autoConfirmSvg"></bit-icon>
|
||||
<bit-svg class="tw-w-[233px]" [content]="autoConfirmSvg"></bit-svg>
|
||||
</div>
|
||||
<ol>
|
||||
<li>1. {{ "autoConfirmExtension1" | i18n }}</li>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
formControlName="minLength"
|
||||
id="minLength"
|
||||
[min]="MinPasswordLength"
|
||||
[max]="MaxPasswordLength"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { MasterPasswordPolicyComponent } from "./master-password.component";
|
||||
|
||||
describe("MasterPasswordPolicyComponent", () => {
|
||||
let component: MasterPasswordPolicyComponent;
|
||||
let fixture: ComponentFixture<MasterPasswordPolicyComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
|
||||
{ provide: AccountService, useValue: mock<AccountService>() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MasterPasswordPolicyComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should accept minimum password length of 12", () => {
|
||||
component.data.patchValue({ minLength: 12 });
|
||||
|
||||
expect(component.data.get("minLength")?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept maximum password length of 128", () => {
|
||||
component.data.patchValue({ minLength: 128 });
|
||||
|
||||
expect(component.data.get("minLength")?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject password length below minimum", () => {
|
||||
component.data.patchValue({ minLength: 11 });
|
||||
|
||||
expect(component.data.get("minLength")?.hasError("min")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject password length above maximum", () => {
|
||||
component.data.patchValue({ minLength: 129 });
|
||||
|
||||
expect(component.data.get("minLength")?.hasError("max")).toBe(true);
|
||||
});
|
||||
|
||||
it("should use correct minimum from Utils", () => {
|
||||
expect(component.MinPasswordLength).toBe(Utils.minimumPasswordLength);
|
||||
expect(component.MinPasswordLength).toBe(12);
|
||||
});
|
||||
|
||||
it("should use correct maximum from Utils", () => {
|
||||
expect(component.MaxPasswordLength).toBe(Utils.maximumPasswordLength);
|
||||
expect(component.MaxPasswordLength).toBe(128);
|
||||
});
|
||||
|
||||
it("should have password scores from 0 to 4", () => {
|
||||
const scores = component.passwordScores.filter((s) => s.value !== null).map((s) => s.value);
|
||||
|
||||
expect(scores).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,14 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition {
|
||||
})
|
||||
export class MasterPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit {
|
||||
MinPasswordLength = Utils.minimumPasswordLength;
|
||||
MaxPasswordLength = Utils.maximumPasswordLength;
|
||||
|
||||
data: FormGroup<ControlsOf<MasterPasswordPolicyOptions>> = this.formBuilder.group({
|
||||
minComplexity: [null],
|
||||
minLength: [this.MinPasswordLength, [Validators.min(Utils.minimumPasswordLength)]],
|
||||
minLength: [
|
||||
this.MinPasswordLength,
|
||||
[Validators.min(Utils.minimumPasswordLength), Validators.max(this.MaxPasswordLength)],
|
||||
],
|
||||
requireUpper: [false],
|
||||
requireLower: [false],
|
||||
requireNumbers: [false],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import {
|
||||
@@ -42,8 +42,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
|
||||
});
|
||||
|
||||
it("input selected on load when policy enabled", async () => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
id: "policy1",
|
||||
component.policyResponse = new PolicyStatusResponse({
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: true,
|
||||
@@ -63,8 +62,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
|
||||
});
|
||||
|
||||
it("input not selected on load when policy disabled", async () => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
id: "policy1",
|
||||
component.policyResponse = new PolicyStatusResponse({
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: false,
|
||||
@@ -84,8 +82,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
|
||||
});
|
||||
|
||||
it("turn on message label", async () => {
|
||||
component.policyResponse = new PolicyResponse({
|
||||
id: "policy1",
|
||||
component.policyResponse = new PolicyStatusResponse({
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: false,
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
AutoConfirmPolicyDialogComponent,
|
||||
AutoConfirmPolicyDialogData,
|
||||
} from "./auto-confirm-edit-policy-dialog.component";
|
||||
|
||||
describe("AutoConfirmPolicyDialogComponent", () => {
|
||||
let component: AutoConfirmPolicyDialogComponent;
|
||||
let fixture: ComponentFixture<AutoConfirmPolicyDialogComponent>;
|
||||
|
||||
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockAutoConfirmService: MockProxy<AutomaticUserConfirmationService>;
|
||||
let mockDialogRef: MockProxy<DialogRef>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockKeyService: MockProxy<KeyService>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockOrgId = newGuid() as OrganizationId;
|
||||
|
||||
const mockDialogData: AutoConfirmPolicyDialogData = {
|
||||
organizationId: mockOrgId,
|
||||
policy: {
|
||||
name: "autoConfirm",
|
||||
description: "Auto Confirm Policy",
|
||||
type: PolicyType.AutoConfirm,
|
||||
component: {} as any,
|
||||
showDescription: true,
|
||||
display$: () => of(true),
|
||||
},
|
||||
firstTimeDialog: false,
|
||||
};
|
||||
|
||||
const mockOrg = {
|
||||
id: mockOrgId,
|
||||
name: "Test Organization",
|
||||
enabled: true,
|
||||
isAdmin: true,
|
||||
canManagePolicies: true,
|
||||
} as Organization;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockAutoConfirmService = mock<AutomaticUserConfirmationService>();
|
||||
mockDialogRef = mock<DialogRef>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockKeyService = mock<KeyService>();
|
||||
|
||||
mockPolicyService.policies$.mockReturnValue(of([]));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutoConfirmPolicyDialogComponent],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: DIALOG_DATA, useValue: mockDialogData },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: KeyService, useValue: mockKeyService },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(AutoConfirmPolicyDialogComponent, {
|
||||
set: { template: "<div></div>" },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("handleSubmit", () => {
|
||||
beforeEach(() => {
|
||||
// Mock the policyComponent
|
||||
component.policyComponent = {
|
||||
buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }),
|
||||
enabled: { value: true },
|
||||
setSingleOrgEnabled: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockAutoConfirmService.configuration$.mockReturnValue(
|
||||
of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }),
|
||||
);
|
||||
mockAutoConfirmService.upsert.mockResolvedValue(undefined);
|
||||
mockI18nService.t.mockReturnValue("Policy updated");
|
||||
});
|
||||
|
||||
it("should enable SingleOrg policy when it was not already enabled", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
// Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled)
|
||||
await component["handleSubmit"](false);
|
||||
|
||||
// First call should be SingleOrg enable
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not enable SingleOrg policy when it was already enabled", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
// Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled)
|
||||
await component["handleSubmit"](true);
|
||||
|
||||
// Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg)
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => {
|
||||
const autoConfirmError = new Error("AutoConfirm failed");
|
||||
|
||||
// First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds
|
||||
mockPolicyApiService.putPolicyVNext
|
||||
.mockResolvedValueOnce({} as any) // SingleOrg enable
|
||||
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
|
||||
.mockResolvedValueOnce({} as any); // SingleOrg rollback
|
||||
|
||||
await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed");
|
||||
|
||||
// Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: false, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => {
|
||||
const autoConfirmError = new Error("AutoConfirm failed");
|
||||
|
||||
// AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls)
|
||||
mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError);
|
||||
|
||||
await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed");
|
||||
|
||||
// Verify only AutoConfirm was called (no SingleOrg enable/rollback)
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep both policies enabled when both submissions succeed", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["handleSubmit"](false);
|
||||
|
||||
// Verify two calls: SingleOrg enable and AutoConfirm enable
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrgId,
|
||||
PolicyType.AutoConfirm,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should re-throw the error after rollback", async () => {
|
||||
const autoConfirmError = new Error("Network error");
|
||||
|
||||
mockPolicyApiService.putPolicyVNext
|
||||
.mockResolvedValueOnce({} as any) // SingleOrg enable
|
||||
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
|
||||
.mockResolvedValueOnce({} as any); // SingleOrg rollback
|
||||
|
||||
await expect(component["handleSubmit"](false)).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSingleOrgPolicy", () => {
|
||||
it("should call putPolicyVNext with enabled: true when enabling", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["setSingleOrgPolicy"](true);
|
||||
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: true, data: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("should call putPolicyVNext with enabled: false when disabling", async () => {
|
||||
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
|
||||
|
||||
await component["setSingleOrgPolicy"](false);
|
||||
|
||||
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
|
||||
mockOrgId,
|
||||
PolicyType.SingleOrg,
|
||||
{ policy: { enabled: false, data: null } },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -181,10 +181,21 @@ export class AutoConfirmPolicyDialogComponent
|
||||
}
|
||||
|
||||
private async handleSubmit(singleOrgEnabled: boolean) {
|
||||
if (!singleOrgEnabled) {
|
||||
await this.submitSingleOrg();
|
||||
const enabledSingleOrgDuringAction = !singleOrgEnabled;
|
||||
|
||||
if (enabledSingleOrgDuringAction) {
|
||||
await this.setSingleOrgPolicy(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.submitAutoConfirm();
|
||||
} catch (error) {
|
||||
// Roll back SingleOrg if we enabled it during this action
|
||||
if (enabledSingleOrgDuringAction) {
|
||||
await this.setSingleOrgPolicy(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.submitAutoConfirm();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,11 +209,9 @@ export class AutoConfirmPolicyDialogComponent
|
||||
|
||||
const autoConfirmRequest = await this.policyComponent.buildRequest();
|
||||
|
||||
await this.policyApiService.putPolicy(
|
||||
this.data.organizationId,
|
||||
this.data.policy.type,
|
||||
autoConfirmRequest,
|
||||
);
|
||||
await this.policyApiService.putPolicyVNext(this.data.organizationId, this.data.policy.type, {
|
||||
policy: autoConfirmRequest,
|
||||
});
|
||||
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
@@ -225,17 +234,15 @@ export class AutoConfirmPolicyDialogComponent
|
||||
}
|
||||
}
|
||||
|
||||
private async submitSingleOrg(): Promise<void> {
|
||||
private async setSingleOrgPolicy(enabled: boolean): Promise<void> {
|
||||
const singleOrgRequest: PolicyRequest = {
|
||||
enabled: true,
|
||||
enabled,
|
||||
data: null,
|
||||
};
|
||||
|
||||
await this.policyApiService.putPolicyVNext(
|
||||
this.data.organizationId,
|
||||
PolicyType.SingleOrg,
|
||||
singleOrgRequest,
|
||||
);
|
||||
await this.policyApiService.putPolicyVNext(this.data.organizationId, PolicyType.SingleOrg, {
|
||||
policy: singleOrgRequest,
|
||||
});
|
||||
}
|
||||
|
||||
private async openBrowserExtension() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<bit-dialog [disablePadding]="!loading" dialogSize="large">
|
||||
<span bitDialogTitle>
|
||||
<ng-container *ngIf="editMode">
|
||||
{{ "editCollection" | i18n }}
|
||||
{{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }}
|
||||
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading">{{
|
||||
collection.name
|
||||
}}</span>
|
||||
@@ -63,7 +63,7 @@
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</bit-tab>
|
||||
<bit-tab label="{{ 'access' | i18n }}">
|
||||
<bit-tab [label]="accessTabLabel">
|
||||
<div class="tw-mb-3">
|
||||
<ng-container *ngIf="dialogReadonly">
|
||||
<span>{{ "readOnlyCollectionAccess" | i18n }}</span>
|
||||
|
||||
@@ -361,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
return this.params.readonly === true;
|
||||
}
|
||||
|
||||
protected get accessTabLabel(): string {
|
||||
return this.dialogReadonly
|
||||
? this.i18nService.t("viewAccess")
|
||||
: this.i18nService.t("editAccess");
|
||||
}
|
||||
|
||||
protected async cancel() {
|
||||
this.close(CollectionDialogAction.Canceled);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="tw-mt-10 tw-flex tw-justify-center" *ngIf="loading">
|
||||
<div>
|
||||
<bit-icon class="tw-w-72 tw-block tw-mb-4" [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n">
|
||||
</bit-icon>
|
||||
<bit-svg class="tw-w-72 tw-block tw-mb-4" [content]="logo" [ariaLabel]="'appLogoLabel' | i18n">
|
||||
</bit-svg>
|
||||
<div class="tw-flex tw-justify-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BitwardenLogo } from "@bitwarden/assets/svg";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { IconModule, ToastService } from "@bitwarden/components";
|
||||
import { SvgModule, ToastService } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BaseAcceptComponent } from "../../../common/base.accept.component";
|
||||
@@ -22,7 +22,7 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component";
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "accept-family-sponsorship.component.html",
|
||||
imports: [CommonModule, I18nPipe, IconModule],
|
||||
imports: [CommonModule, I18nPipe, SvgModule],
|
||||
})
|
||||
export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent {
|
||||
protected logo = BitwardenLogo;
|
||||
|
||||
@@ -90,6 +90,10 @@ describe("WebSetInitialPasswordService", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties
|
||||
* in the test setup above that are now un-used and can also be removed.
|
||||
*/
|
||||
describe("setInitialPassword(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordCredentials;
|
||||
@@ -119,6 +123,8 @@ describe("WebSetInitialPasswordService", () => {
|
||||
orgSsoIdentifier: "orgSsoIdentifier",
|
||||
orgId: "orgId",
|
||||
resetPasswordAutoEnroll: false,
|
||||
newPassword: "Test@Password123!",
|
||||
salt: "user@example.com" as MasterPasswordSalt,
|
||||
};
|
||||
userId = "userId" as UserId;
|
||||
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
|
||||
|
||||
@@ -56,6 +56,9 @@ export class WebSetInitialPasswordService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated To be removed in PM-28143
|
||||
*/
|
||||
override async setInitialPassword(
|
||||
credentials: SetInitialPasswordCredentials,
|
||||
userType: SetInitialPasswordUserType,
|
||||
|
||||
@@ -166,5 +166,13 @@ describe("EmergencyViewDialogComponent", () => {
|
||||
|
||||
expect(component["title"]).toBe("viewItemHeaderNote");
|
||||
});
|
||||
|
||||
it("sets ssh key title", () => {
|
||||
mockCipher.type = CipherType.SshKey;
|
||||
|
||||
component["updateTitle"]();
|
||||
|
||||
expect(component["title"]).toBe("viewItemHeaderSshKey");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,9 @@ export class EmergencyViewDialogComponent {
|
||||
case CipherType.SecureNote:
|
||||
this.title = this.i18nService.t("viewItemHeaderNote");
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.title = this.i18nService.t("viewItemHeaderSshKey");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
@@ -68,7 +68,7 @@ declare global {
|
||||
TypographyModule,
|
||||
CalloutModule,
|
||||
ButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
I18nPipe,
|
||||
AsyncActionsModule,
|
||||
JslibModule,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
@@ -42,7 +42,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas
|
||||
InputModule,
|
||||
TypographyModule,
|
||||
ButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
I18nPipe,
|
||||
ReactiveFormsModule,
|
||||
AsyncActionsModule,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
@@ -45,7 +45,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
I18nPipe,
|
||||
InputModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div *ngIf="currentStep === 'credentialCreation'" class="tw-flex tw-flex-col tw-items-center">
|
||||
<div class="tw-size-24 tw-content-center tw-mb-6">
|
||||
<bit-icon [icon]="Icons.TwoFactorAuthSecurityKeyIcon"></bit-icon>
|
||||
<bit-svg [content]="Icons.TwoFactorAuthSecurityKeyIcon"></bit-svg>
|
||||
</div>
|
||||
<h3 bitTypography="h3">{{ "creatingPasskeyLoading" | i18n }}</h3>
|
||||
<p bitTypography="body1">{{ "creatingPasskeyLoadingInfo" | i18n }}</p>
|
||||
@@ -27,7 +27,7 @@
|
||||
class="tw-flex tw-flex-col tw-items-center"
|
||||
>
|
||||
<div class="tw-size-24 tw-content-center tw-mb-6">
|
||||
<bit-icon [icon]="Icons.TwoFactorAuthSecurityKeyFailedIcon"></bit-icon>
|
||||
<bit-svg [content]="Icons.TwoFactorAuthSecurityKeyFailedIcon"></bit-svg>
|
||||
</div>
|
||||
<h3 bitTypography="h3">{{ "errorCreatingPasskey" | i18n }}</h3>
|
||||
<p bitTypography="body1">{{ "errorCreatingPasskeyInfo" | i18n }}</p>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Injectable } from "@angular/core";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response";
|
||||
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { BitwardenSubscription } from "@bitwarden/subscription";
|
||||
|
||||
import {
|
||||
@@ -59,7 +57,7 @@ export class AccountBillingClient {
|
||||
|
||||
upgradePremiumToOrganization = async (
|
||||
organizationName: string,
|
||||
organizationKey: EncString,
|
||||
organizationKey: string,
|
||||
planTier: ProductTierType,
|
||||
cadence: SubscriptionCadence,
|
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
||||
@@ -68,7 +66,13 @@ export class AccountBillingClient {
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
path,
|
||||
{ organizationName, key: organizationKey, tier: planTier, cadence, billingAddress },
|
||||
{
|
||||
organizationName,
|
||||
key: organizationKey,
|
||||
targetProductTierType: planTier,
|
||||
cadence,
|
||||
billingAddress,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -21,6 +21,8 @@ export class ProrationPreviewResponse extends BaseResponse {
|
||||
tax: number;
|
||||
total: number;
|
||||
credit: number;
|
||||
newPlanProratedMonths: number;
|
||||
newPlanProratedAmount: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -28,6 +30,8 @@ export class ProrationPreviewResponse extends BaseResponse {
|
||||
this.tax = this.getResponseProperty("Tax");
|
||||
this.total = this.getResponseProperty("Total");
|
||||
this.credit = this.getResponseProperty("Credit");
|
||||
this.newPlanProratedMonths = this.getResponseProperty("NewPlanProratedMonths");
|
||||
this.newPlanProratedAmount = this.getResponseProperty("NewPlanProratedAmount");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,12 +57,8 @@
|
||||
<ng-container *ngIf="subscription">
|
||||
<ng-container *ngIf="enableDiscountDisplay$ | async as enableDiscount; else noDiscount">
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
|
||||
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
|
||||
{{
|
||||
(sub.subscription.periodEndDate | date: "MMM d, y") +
|
||||
", " +
|
||||
(discountedSubscriptionAmount | currency: "$")
|
||||
}}
|
||||
<span [attr.aria-label]="'nextChargeDate' | i18n">
|
||||
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
|
||||
</span>
|
||||
<billing-discount-badge
|
||||
[discount]="getDiscount(sub?.customerDiscount)"
|
||||
@@ -71,12 +67,8 @@
|
||||
</ng-container>
|
||||
<ng-template #noDiscount>
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
|
||||
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
|
||||
{{
|
||||
(sub.subscription.periodEndDate | date: "MMM d, y") +
|
||||
", " +
|
||||
(subscriptionAmount | currency: "$")
|
||||
}}
|
||||
<span [attr.aria-label]="'nextChargeDate' | i18n">
|
||||
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
<ng-template #organizationIsNotManagedByConsolidatedBillingMSP>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
|
||||
<div class="tw-size-56 tw-content-center">
|
||||
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
|
||||
<bit-svg [content]="gearIcon" aria-hidden="true"></bit-svg>
|
||||
</div>
|
||||
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: userOrg.providerName }}</p>
|
||||
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { GearIcon } from "@bitwarden/assets/svg";
|
||||
selector: "app-org-subscription-hidden",
|
||||
template: `<div class="tw-flex tw-flex-col tw-items-center tw-text-info">
|
||||
<div class="tw-size-56 tw-content-center">
|
||||
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
|
||||
<bit-svg [content]="gearIcon" aria-hidden="true"></bit-svg>
|
||||
</div>
|
||||
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: providerName }}</p>
|
||||
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<h3 bitTypography="h3">{{ "moreFromBitwarden" | i18n }}</h3>
|
||||
<div class="tw-rounded-t tw-bg-background-alt3 tw-p-5">
|
||||
<div class="tw-w-72">
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
<bit-svg [content]="logo"></bit-svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -65,6 +65,7 @@ import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
@@ -127,6 +128,8 @@ import {
|
||||
} from "@bitwarden/key-management";
|
||||
import {
|
||||
LockComponentService,
|
||||
WebAuthnPrfUnlockService,
|
||||
DefaultWebAuthnPrfUnlockService,
|
||||
SessionTimeoutSettingsComponentService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
|
||||
@@ -495,6 +498,21 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: NoopAuthRequestAnsweringService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebAuthnPrfUnlockService,
|
||||
useClass: DefaultWebAuthnPrfUnlockService,
|
||||
deps: [
|
||||
WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
KeyServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
EncryptService,
|
||||
EnvironmentService,
|
||||
PlatformUtilsService,
|
||||
WINDOW,
|
||||
LogService,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -522,16 +522,25 @@ export class EventService {
|
||||
break;
|
||||
// Org Domain claiming events
|
||||
case EventType.OrganizationDomain_Added:
|
||||
msg = humanReadableMsg = this.i18nService.t("addedDomain", ev.domainName);
|
||||
msg = humanReadableMsg = this.i18nService.t("addedDomain", this.escapeHtml(ev.domainName));
|
||||
break;
|
||||
case EventType.OrganizationDomain_Removed:
|
||||
msg = humanReadableMsg = this.i18nService.t("removedDomain", ev.domainName);
|
||||
msg = humanReadableMsg = this.i18nService.t(
|
||||
"removedDomain",
|
||||
this.escapeHtml(ev.domainName),
|
||||
);
|
||||
break;
|
||||
case EventType.OrganizationDomain_Verified:
|
||||
msg = humanReadableMsg = this.i18nService.t("domainClaimedEvent", ev.domainName);
|
||||
msg = humanReadableMsg = this.i18nService.t(
|
||||
"domainClaimedEvent",
|
||||
this.escapeHtml(ev.domainName),
|
||||
);
|
||||
break;
|
||||
case EventType.OrganizationDomain_NotVerified:
|
||||
msg = humanReadableMsg = this.i18nService.t("domainNotClaimedEvent", ev.domainName);
|
||||
msg = humanReadableMsg = this.i18nService.t(
|
||||
"domainNotClaimedEvent",
|
||||
this.escapeHtml(ev.domainName),
|
||||
);
|
||||
break;
|
||||
// Secrets Manager
|
||||
case EventType.Secret_Retrieved:
|
||||
@@ -713,6 +722,8 @@ export class EventService {
|
||||
return ["bwi-browser", this.i18nService.t("webVault") + " - Edge"];
|
||||
case DeviceType.IEBrowser:
|
||||
return ["bwi-browser", this.i18nService.t("webVault") + " - IE"];
|
||||
case DeviceType.DuckDuckGoBrowser:
|
||||
return ["bwi-browser", this.i18nService.t("webVault") + " - DuckDuckGo"];
|
||||
case DeviceType.Server:
|
||||
return ["bwi-user-monitor", this.i18nService.t("server")];
|
||||
case DeviceType.WindowsCLI:
|
||||
@@ -893,6 +904,15 @@ export class EventService {
|
||||
return id?.substring(0, 8);
|
||||
}
|
||||
|
||||
private escapeHtml(unsafe: string): string {
|
||||
if (!unsafe) {
|
||||
return unsafe;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
private toDateTimeLocalString(date: Date) {
|
||||
return (
|
||||
date.getFullYear() +
|
||||
|
||||
@@ -12,45 +12,54 @@
|
||||
{{ "checkBreaches" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="tw-mt-4" *ngIf="!loading && checkedUsername">
|
||||
<p *ngIf="error">{{ "reportError" | i18n }}...</p>
|
||||
<ng-container *ngIf="!error">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!breachedAccounts.length">
|
||||
{{ "breachUsernameNotFound" | i18n: checkedUsername }}
|
||||
</bit-callout>
|
||||
<bit-callout type="danger" title="{{ 'breachFound' | i18n }}" *ngIf="breachedAccounts.length">
|
||||
{{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }}
|
||||
</bit-callout>
|
||||
<ul
|
||||
class="tw-list-none tw-flex-col tw-divide-x-0 tw-divide-y tw-divide-solid tw-divide-secondary-300 tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-p-0"
|
||||
*ngIf="breachedAccounts.length"
|
||||
>
|
||||
<li *ngFor="let a of breachedAccounts" class="tw-flex tw-gap-4 tw-p-4">
|
||||
<div class="tw-w-32 tw-flex-none">
|
||||
<img [src]="a.logoPath" alt="" class="tw-max-w-32 tw-items-stretch" />
|
||||
</div>
|
||||
<div class="tw-flex-auto">
|
||||
<h3 class="tw-text-lg">{{ a.title }}</h3>
|
||||
<p [innerHTML]="a.description"></p>
|
||||
<p class="tw-mb-1">{{ "compromisedData" | i18n }}:</p>
|
||||
<ul>
|
||||
<li *ngFor="let d of a.dataClasses">{{ d }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tw-w-48 tw-flex-none">
|
||||
<dl>
|
||||
<dt>{{ "website" | i18n }}</dt>
|
||||
<dd>{{ a.domain }}</dd>
|
||||
<dt>{{ "affectedUsers" | i18n }}</dt>
|
||||
<dd>{{ a.pwnCount | number }}</dd>
|
||||
<dt>{{ "breachOccurred" | i18n }}</dt>
|
||||
<dd>{{ a.breachDate | date: "mediumDate" }}</dd>
|
||||
<dt>{{ "breachReported" | i18n }}</dt>
|
||||
<dd>{{ a.addedDate | date: "mediumDate" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
</div>
|
||||
@if (!loading && checkedUsername) {
|
||||
<div class="tw-mt-4">
|
||||
@if (error) {
|
||||
<p>{{ "reportError" | i18n }}...</p>
|
||||
} @else {
|
||||
@if (!breachedAccounts.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "breachUsernameNotFound" | i18n: checkedUsername }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'breachFound' | i18n }}">
|
||||
{{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }}
|
||||
</bit-callout>
|
||||
<ul
|
||||
class="tw-list-none tw-flex-col tw-divide-x-0 tw-divide-y tw-divide-solid tw-divide-secondary-300 tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-p-0"
|
||||
>
|
||||
@for (a of breachedAccounts; track a) {
|
||||
<li class="tw-flex tw-gap-4 tw-p-4">
|
||||
<div class="tw-w-32 tw-flex-none">
|
||||
<img [src]="a.logoPath" alt="" class="tw-max-w-32 tw-items-stretch" />
|
||||
</div>
|
||||
<div class="tw-flex-auto">
|
||||
<h3 class="tw-text-lg">{{ a.title }}</h3>
|
||||
<p [innerHTML]="a.description"></p>
|
||||
<p class="tw-mb-1">{{ "compromisedData" | i18n }}:</p>
|
||||
<ul>
|
||||
@for (d of a.dataClasses; track d) {
|
||||
<li>{{ d }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tw-w-48 tw-flex-none">
|
||||
<dl>
|
||||
<dt>{{ "website" | i18n }}</dt>
|
||||
<dd>{{ a.domain }}</dd>
|
||||
<dt>{{ "affectedUsers" | i18n }}</dt>
|
||||
<dd>{{ a.pwnCount | number }}</dd>
|
||||
<dt>{{ "breachOccurred" | i18n }}</dt>
|
||||
<dd>{{ a.breachDate | date: "mediumDate" }}</dd>
|
||||
<dt>{{ "breachReported" | i18n }}</dt>
|
||||
<dd>{{ a.addedDate | date: "mediumDate" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -46,8 +46,11 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
organizations: Organization[] = [];
|
||||
organizations$: Observable<Organization[]>;
|
||||
|
||||
readonly maxItemsToSwitchToChipSelect = 5;
|
||||
filterStatus: any = [0];
|
||||
showFilterToggle: boolean = false;
|
||||
selectedFilterChip: string = "0";
|
||||
chipSelectOptions: { label: string; value: string }[] = [];
|
||||
vaultMsg: string = "vault";
|
||||
currentFilterStatus: number | string = 0;
|
||||
protected filterOrgStatus$ = new BehaviorSubject<number | string>(0);
|
||||
@@ -190,6 +193,7 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
formConfig,
|
||||
activeCollectionId,
|
||||
disableForm,
|
||||
isAdminConsoleAction: true,
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(this.vaultItemDialogRef.closed);
|
||||
@@ -288,6 +292,15 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
return await this.cipherService.getAllDecrypted(activeUserId);
|
||||
}
|
||||
|
||||
protected canDisplayToggleGroup(): boolean {
|
||||
return this.filterStatus.length <= this.maxItemsToSwitchToChipSelect;
|
||||
}
|
||||
|
||||
async filterOrgToggleChipSelect(filterId: string | null) {
|
||||
const selectedFilterId = filterId ?? 0;
|
||||
await this.filterOrgToggle(selectedFilterId);
|
||||
}
|
||||
|
||||
protected filterCiphersByOrg(ciphersList: CipherView[]) {
|
||||
this.allCiphers = [...ciphersList];
|
||||
|
||||
@@ -309,5 +322,22 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
this.showFilterToggle = false;
|
||||
this.vaultMsg = "vault";
|
||||
}
|
||||
|
||||
this.chipSelectOptions = this.setupChipSelectOptions(this.filterStatus);
|
||||
}
|
||||
|
||||
private setupChipSelectOptions(filters: string[]) {
|
||||
const options = filters.map((filterId: string, index: number) => {
|
||||
const name = this.getName(filterId);
|
||||
const count = this.getCount(filterId);
|
||||
const labelSuffix = count != null ? ` (${count})` : "";
|
||||
|
||||
return {
|
||||
label: name + labelSuffix,
|
||||
value: filterId,
|
||||
};
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,95 +5,119 @@
|
||||
<button type="submit" buttonType="primary" bitButton [loading]="loading" (click)="load()">
|
||||
{{ "checkExposedPasswords" | i18n }}
|
||||
</button>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noExposedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'exposedPasswordsFound' | i18n }}" [useAlertRole]="true">
|
||||
{{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
|
||||
{{ "timesExposed" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell *ngIf="!isAdminConsoleActive">
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (hasLoaded) {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noExposedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout
|
||||
type="danger"
|
||||
title="{{ 'exposedPasswordsFound' | i18n }}"
|
||||
[useAlertRole]="true"
|
||||
>
|
||||
{{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell bitSortable="organizationId">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
}
|
||||
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
|
||||
{{ "timesExposed" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="
|
||||
row.organizationId | orgNameFromId: (organizations$ | async)
|
||||
"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -2,104 +2,124 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "inactive2faReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noInactive2fa" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'inactive2faFound' | i18n }}">
|
||||
{{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noInactive2fa" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'inactive2faFound' | i18n }}">
|
||||
{{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<a
|
||||
bitBadge
|
||||
href="{{ cipherDocs.get(row.id) }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
*ngIf="cipherDocs.has(row.id)"
|
||||
>
|
||||
{{ "instructions" | i18n }}</a
|
||||
>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</ng-container>
|
||||
}
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<ng-container>
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-template>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<ng-container>
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<ng-container>
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
@if (cipherDocs.has(row.id)) {
|
||||
<a bitBadge href="{{ cipherDocs.get(row.id) }}" target="_blank" rel="noreferrer">
|
||||
{{ "instructions" | i18n }}</a
|
||||
>
|
||||
}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
PasswordRepromptService,
|
||||
CipherFormConfigService,
|
||||
@@ -45,7 +45,7 @@ import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class ExposedPasswordsReportComponent
|
||||
extends BaseExposedPasswordsReportComponent
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -39,7 +39,7 @@ import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponen
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class InactiveTwoFactorReportComponent
|
||||
extends BaseInactiveTwoFactorReportComponent
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -44,7 +44,7 @@ import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent }
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class ReusedPasswordsReportComponent
|
||||
extends BaseReusedPasswordsReportComponent
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -44,7 +44,7 @@ import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponen
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class UnsecuredWebsitesReportComponent
|
||||
extends BaseUnsecuredWebsitesReportComponent
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -45,7 +45,7 @@ import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class WeakPasswordsReportComponent
|
||||
extends BaseWeakPasswordsReportComponent
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
<bit-container>
|
||||
<p>{{ "reportsDesc" | i18n }}</p>
|
||||
|
||||
<app-report-list [reports]="reports"></app-report-list>
|
||||
<app-report-list [reports]="reports()"></app-report-list>
|
||||
</bit-container>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, OnInit, signal } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -9,15 +9,14 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { reports, ReportType } from "../reports";
|
||||
import { ReportEntry, ReportVariant } from "../shared";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-reports-home",
|
||||
templateUrl: "reports-home.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class ReportsHomeComponent implements OnInit {
|
||||
reports: ReportEntry[];
|
||||
readonly reports = signal<ReportEntry[]>([]);
|
||||
|
||||
constructor(
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
@@ -33,7 +32,7 @@ export class ReportsHomeComponent implements OnInit {
|
||||
? ReportVariant.Enabled
|
||||
: ReportVariant.RequiresPremium;
|
||||
|
||||
this.reports = [
|
||||
this.reports.set([
|
||||
{
|
||||
...reports[ReportType.ExposedPasswords],
|
||||
variant: reportRequiresPremium,
|
||||
@@ -58,6 +57,6 @@ export class ReportsHomeComponent implements OnInit {
|
||||
...reports[ReportType.DataBreach],
|
||||
variant: ReportVariant.Enabled,
|
||||
},
|
||||
];
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,100 +2,115 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "reusedPasswordsReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noReusedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'reusedPasswordsFound' | i18n }}">
|
||||
{{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noReusedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'reusedPasswordsFound' | i18n }}">
|
||||
{{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
</ng-container>
|
||||
}
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -2,93 +2,109 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "unsecuredWebsitesReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noUnsecuredWebsites" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'unsecuredWebsitesFound' | i18n }}">
|
||||
{{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noUnsecuredWebsites" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'unsecuredWebsitesFound' | i18n }}">
|
||||
{{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
</ng-container>
|
||||
}
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -2,102 +2,123 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "weakPasswordsReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noWeakPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}">
|
||||
{{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
<th bitCell class="tw-text-right" bitSortable="scoreKey" default>
|
||||
{{ "weakness" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell *ngIf="!isAdminConsoleActive">
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noWeakPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}">
|
||||
{{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge [variant]="row.reportValue.badgeVariant">
|
||||
{{ row.reportValue.label | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell bitSortable="organizationId">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
}
|
||||
<th bitCell class="tw-text-right" bitSortable="scoreKey" default="desc">
|
||||
{{ "weakness" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="
|
||||
row.organizationId | orgNameFromId: (organizations$ | async)
|
||||
"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge [variant]="row.reportValue.badgeVariant">
|
||||
{{ row.reportValue.label | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { ChipSelectComponent } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
DefaultCipherFormConfigService,
|
||||
@@ -34,6 +35,7 @@ import { ReportsSharedModule } from "./shared";
|
||||
OrganizationBadgeModule,
|
||||
PipesModule,
|
||||
HeaderModule,
|
||||
ChipSelectComponent,
|
||||
],
|
||||
declarations: [
|
||||
BreachReportComponent,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Icon } from "@bitwarden/assets/svg";
|
||||
import { BitSvg } from "@bitwarden/assets/svg";
|
||||
|
||||
import { ReportVariant } from "./report-variant";
|
||||
|
||||
@@ -6,6 +6,6 @@ export type ReportEntry = {
|
||||
title: string;
|
||||
description: string;
|
||||
route: string;
|
||||
icon: Icon;
|
||||
icon: BitSvg;
|
||||
variant: ReportVariant;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[ngClass]="{ 'tw-grayscale': disabled }"
|
||||
>
|
||||
<div class="tw-m-auto tw-size-20 tw-content-center">
|
||||
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon>
|
||||
<bit-svg [content]="icon" aria-hidden="true" class="tw-h-full"></bit-svg>
|
||||
</div>
|
||||
</div>
|
||||
<bit-card-content [ngClass]="{ 'tw-grayscale': disabled }">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { Icon } from "@bitwarden/assets/svg";
|
||||
import { BitSvg } from "@bitwarden/assets/svg";
|
||||
|
||||
import { ReportVariant } from "../models/report-variant";
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ReportCardComponent {
|
||||
@Input() route: string;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() icon: Icon;
|
||||
@Input() icon: BitSvg;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() variant: ReportVariant;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BaseCardComponent,
|
||||
CardContentComponent,
|
||||
I18nMockService,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
JslibModule,
|
||||
BadgeModule,
|
||||
CardContentComponent,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
RouterTestingModule,
|
||||
PremiumBadgeComponent,
|
||||
BaseCardComponent,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<div
|
||||
class="tw-inline-grid tw-place-items-stretch tw-place-content-center tw-grid-cols-1 @xl:tw-grid-cols-2 @4xl:tw-grid-cols-3 tw-gap-4 [&_a]:tw-max-w-none @5xl:[&_a]:tw-max-w-72"
|
||||
>
|
||||
<div *ngFor="let report of reports">
|
||||
<app-report-card
|
||||
[title]="report.title | i18n"
|
||||
[description]="report.description | i18n"
|
||||
[route]="report.route"
|
||||
[variant]="report.variant"
|
||||
[icon]="report.icon"
|
||||
></app-report-card>
|
||||
</div>
|
||||
@for (report of reports(); track report) {
|
||||
<div>
|
||||
<app-report-card
|
||||
[title]="report.title | i18n"
|
||||
[description]="report.description | i18n"
|
||||
[route]="report.route"
|
||||
[variant]="report.variant"
|
||||
[icon]="report.icon"
|
||||
></app-report-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { ReportEntry } from "../models/report-entry";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-report-list",
|
||||
templateUrl: "report-list.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class ReportListComponent {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() reports: ReportEntry[];
|
||||
readonly reports = input<ReportEntry[]>([]);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
BadgeModule,
|
||||
BaseCardComponent,
|
||||
CardContentComponent,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
JslibModule,
|
||||
BadgeModule,
|
||||
RouterTestingModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
PremiumBadgeComponent,
|
||||
CardContentComponent,
|
||||
BaseCardComponent,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom, of } from "rxjs";
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { WebLockComponentService } from "./web-lock-component.service";
|
||||
|
||||
@@ -12,9 +13,11 @@ describe("WebLockComponentService", () => {
|
||||
let service: WebLockComponentService;
|
||||
|
||||
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let webAuthnPrfUnlockService: MockProxy<WebAuthnPrfUnlockService>;
|
||||
|
||||
beforeEach(() => {
|
||||
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -23,6 +26,10 @@ describe("WebLockComponentService", () => {
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
useValue: userDecryptionOptionsService,
|
||||
},
|
||||
{
|
||||
provide: WebAuthnPrfUnlockService,
|
||||
useValue: webAuthnPrfUnlockService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -91,6 +98,7 @@ describe("WebLockComponentService", () => {
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce(
|
||||
of(userDecryptionOptions),
|
||||
);
|
||||
webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false);
|
||||
|
||||
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
|
||||
|
||||
@@ -105,6 +113,9 @@ describe("WebLockComponentService", () => {
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
prf: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { map, Observable } from "rxjs";
|
||||
import { combineLatest, defer, map, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui";
|
||||
import {
|
||||
LockComponentService,
|
||||
UnlockOptions,
|
||||
WebAuthnPrfUnlockService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
|
||||
export class WebLockComponentService implements LockComponentService {
|
||||
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
|
||||
private readonly webAuthnPrfUnlockService = inject(WebAuthnPrfUnlockService);
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -43,8 +45,14 @@ export class WebLockComponentService implements LockComponentService {
|
||||
}
|
||||
|
||||
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions | null> {
|
||||
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId)?.pipe(
|
||||
map((userDecryptionOptions: UserDecryptionOptions) => {
|
||||
return combineLatest([
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
defer(async () => {
|
||||
const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId);
|
||||
return { available };
|
||||
}),
|
||||
]).pipe(
|
||||
map(([userDecryptionOptions, prfUnlockInfo]) => {
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
@@ -56,6 +64,9 @@ export class WebLockComponentService implements LockComponentService {
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
prf: {
|
||||
enabled: prfUnlockInfo.available,
|
||||
},
|
||||
};
|
||||
return unlockOpts;
|
||||
}),
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
BreadcrumbsModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
MenuModule,
|
||||
NavigationModule,
|
||||
@@ -94,7 +94,7 @@ export default {
|
||||
BreadcrumbsModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
MenuModule,
|
||||
TabsModule,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
import { SvgModule } from "@bitwarden/components";
|
||||
|
||||
import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component";
|
||||
|
||||
@@ -32,7 +32,7 @@ import { WebLayoutModule } from "./web-layout.module";
|
||||
RouterModule,
|
||||
JslibModule,
|
||||
WebLayoutModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
BillingFreeFamiliesNavItemComponent,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an
|
||||
import { delay, of, startWith } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components";
|
||||
import { LinkModule, SvgModule, ProgressModule } from "@bitwarden/components";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../core/tests";
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
component: OnboardingComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [JslibModule, RouterModule, LinkModule, IconModule, ProgressModule],
|
||||
imports: [JslibModule, RouterModule, LinkModule, SvgModule, ProgressModule],
|
||||
declarations: [OnboardingTaskComponent],
|
||||
}),
|
||||
applicationConfig({
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
LinkModule,
|
||||
MenuModule,
|
||||
MultiSelectModule,
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
LinkModule,
|
||||
MenuModule,
|
||||
MultiSelectModule,
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
LinkModule,
|
||||
MenuModule,
|
||||
MultiSelectModule,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@switch (viewState) {
|
||||
@switch (viewState()) {
|
||||
@case ("auth") {
|
||||
<app-send-auth [id]="id" [key]="key" (accessGranted)="onAccessGranted($event)"></app-send-auth>
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
<app-send-view
|
||||
[id]="id"
|
||||
[key]="key"
|
||||
[accessToken]="sendAccessToken"
|
||||
[sendResponse]="sendAccessResponse"
|
||||
[accessRequest]="sendAccessRequest"
|
||||
(authRequired)="onAuthRequired()"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
|
||||
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
|
||||
|
||||
@@ -17,44 +19,45 @@ const SendViewState = Object.freeze({
|
||||
} as const);
|
||||
type SendViewState = (typeof SendViewState)[keyof typeof SendViewState];
|
||||
|
||||
// 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-send-access",
|
||||
templateUrl: "access.component.html",
|
||||
imports: [SendAuthComponent, SendViewComponent, SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AccessComponent implements OnInit {
|
||||
viewState: SendViewState = SendViewState.View;
|
||||
readonly viewState = signal<SendViewState>(SendViewState.Auth);
|
||||
id: string;
|
||||
key: string;
|
||||
|
||||
sendAccessToken: SendAccessToken | null = null;
|
||||
sendAccessResponse: SendAccessResponse | null = null;
|
||||
sendAccessRequest: SendAccessRequest = new SendAccessRequest();
|
||||
|
||||
constructor(private route: ActivatedRoute) {}
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.params.subscribe(async (params) => {
|
||||
ngOnInit() {
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
this.id = params.sendId;
|
||||
this.key = params.key;
|
||||
|
||||
if (this.id && this.key) {
|
||||
this.viewState = SendViewState.View;
|
||||
this.sendAccessResponse = null;
|
||||
this.sendAccessRequest = new SendAccessRequest();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAuthRequired() {
|
||||
this.viewState = SendViewState.Auth;
|
||||
this.viewState.set(SendViewState.Auth);
|
||||
}
|
||||
|
||||
onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) {
|
||||
onAccessGranted(event: {
|
||||
response?: SendAccessResponse;
|
||||
request?: SendAccessRequest;
|
||||
accessToken?: SendAccessToken;
|
||||
}) {
|
||||
this.sendAccessResponse = event.response;
|
||||
this.sendAccessRequest = event.request;
|
||||
this.viewState = SendViewState.View;
|
||||
this.sendAccessToken = event.accessToken;
|
||||
this.viewState.set(SendViewState.View);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
@if (!enterOtp()) {
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "email" | i18n }}</bit-label>
|
||||
<input bitInput type="text" [formControl]="email" required appInputVerbatim appAutofocus />
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
>
|
||||
<span>{{ "sendCode" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" [formControl]="otp" required appInputVerbatim appAutofocus />
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
>
|
||||
<span>{{ "viewSend" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@Component({
|
||||
selector: "app-send-access-email",
|
||||
templateUrl: "send-access-email.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAccessEmailComponent implements OnInit, OnDestroy {
|
||||
protected readonly formGroup = input.required<FormGroup>();
|
||||
protected readonly enterOtp = input.required<boolean>();
|
||||
protected email: FormControl;
|
||||
protected otp: FormControl;
|
||||
|
||||
readonly loading = input.required<boolean>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {
|
||||
this.email = new FormControl("", Validators.required);
|
||||
this.otp = new FormControl("", Validators.required);
|
||||
this.formGroup().addControl("email", this.email);
|
||||
this.formGroup().addControl("otp", this.otp);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.formGroup().removeControl("email");
|
||||
this.formGroup().removeControl("otp");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<p class="tw-text-wrap tw-break-all">{{ send.file.fileName }}</p>
|
||||
<p class="tw-text-wrap tw-break-all">{{ send().file.fileName }}</p>
|
||||
<button bitButton type="button" buttonType="primary" [bitAction]="download" [block]="true">
|
||||
<i class="bwi bwi-download" aria-hidden="true"></i>
|
||||
{{ "downloadAttachments" | i18n }} ({{ send.file.sizeName }})
|
||||
{{ "downloadAttachments" | i18n }} ({{ send().file.sizeName }})
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -15,40 +18,39 @@ import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
// 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-send-access-file",
|
||||
templateUrl: "send-access-file.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAccessFileComponent {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() send: SendAccessView;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() decKey: SymmetricCryptoKey;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() accessRequest: SendAccessRequest;
|
||||
readonly send = input<SendAccessView | null>(null);
|
||||
readonly decKey = input<SymmetricCryptoKey | null>(null);
|
||||
readonly accessRequest = input<SendAccessRequest | null>(null);
|
||||
readonly accessToken = input<SendAccessToken | null>(null);
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private encryptService: EncryptService,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private sendApiService: SendApiService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
protected download = async () => {
|
||||
if (this.send == null || this.decKey == null) {
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
const accessToken = this.accessToken();
|
||||
const accessRequest = this.accessRequest();
|
||||
const authMissing = (sendEmailOtp && !accessToken) || (!sendEmailOtp && !accessRequest);
|
||||
if (this.send() == null || this.decKey() == null || authMissing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadData = await this.sendApiService.getSendFileDownloadData(
|
||||
this.send,
|
||||
this.accessRequest,
|
||||
);
|
||||
const downloadData = sendEmailOtp
|
||||
? await this.sendApiService.getSendFileDownloadDataV2(this.send(), accessToken)
|
||||
: await this.sendApiService.getSendFileDownloadData(this.send(), accessRequest);
|
||||
|
||||
if (Utils.isNullOrWhitespace(downloadData.url)) {
|
||||
this.toastService.showToast({
|
||||
@@ -71,9 +73,9 @@ export class SendAccessFileComponent {
|
||||
|
||||
try {
|
||||
const encBuf = await EncArrayBuffer.fromResponse(response);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey());
|
||||
this.fileDownloadService.download({
|
||||
fileName: this.send.file.fileName,
|
||||
fileName: this.send().file.fileName,
|
||||
blobData: decBuf,
|
||||
downloadMethod: "save",
|
||||
});
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
<p bitTypography="body1">{{ "sendProtectedPassword" | i18n }}</p>
|
||||
<p bitTypography="body1">{{ "sendProtectedPasswordDontKnow" | i18n }}</p>
|
||||
<div class="tw-mb-3" [formGroup]="formGroup">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "password" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="password"
|
||||
formControlName="password"
|
||||
required
|
||||
appInputVerbatim
|
||||
appAutofocus
|
||||
/>
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading"
|
||||
[block]="true"
|
||||
>
|
||||
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "password" | i18n }}</bit-label>
|
||||
<input bitInput type="password" [formControl]="password" required appInputVerbatim appAutofocus />
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex">
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[loading]="loading()"
|
||||
[block]="true"
|
||||
>
|
||||
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
// 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-send-access-password",
|
||||
templateUrl: "send-access-password.component.html",
|
||||
imports: [SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAccessPasswordComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected formGroup = this.formBuilder.group({
|
||||
password: ["", [Validators.required]],
|
||||
});
|
||||
protected readonly formGroup = input.required<FormGroup>();
|
||||
protected password: FormControl;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() loading: boolean;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() setPasswordEvent = new EventEmitter<string>();
|
||||
readonly loading = input.required<boolean>();
|
||||
|
||||
constructor(private formBuilder: FormBuilder) {}
|
||||
constructor() {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.formGroup.controls.password.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((val) => {
|
||||
this.setPasswordEvent.emit(val);
|
||||
});
|
||||
ngOnInit() {
|
||||
this.password = new FormControl("", Validators.required);
|
||||
this.formGroup().addControl("password", this.password);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.formGroup().removeControl("password");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
<form (ngSubmit)="onSubmit(password)">
|
||||
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
@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>
|
||||
<div class="tw-text-main tw-text-center" *ngIf="error">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
|
||||
<app-send-access-password
|
||||
*ngIf="!unavailable"
|
||||
(setPasswordEvent)="password = $event"
|
||||
[loading]="loading"
|
||||
></app-send-access-password>
|
||||
}
|
||||
<form [formGroup]="sendAccessForm" (ngSubmit)="onSubmit()">
|
||||
@if (error()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (unavailable()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
@switch (sendAuthType()) {
|
||||
@case (authType.Password) {
|
||||
<app-send-access-password
|
||||
[loading]="loading()"
|
||||
[formGroup]="sendAccessForm"
|
||||
></app-send-access-password>
|
||||
}
|
||||
@case (authType.Email) {
|
||||
<app-send-access-email
|
||||
[formGroup]="sendAccessForm"
|
||||
[enterOtp]="enterOtp()"
|
||||
[loading]="loading()"
|
||||
></app-send-access-email>
|
||||
}
|
||||
}
|
||||
}
|
||||
</form>
|
||||
|
||||
@@ -1,86 +1,210 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, OnInit, output, signal } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
emailAndOtpRequired,
|
||||
emailRequired,
|
||||
otpInvalid,
|
||||
passwordHashB64Invalid,
|
||||
passwordHashB64Required,
|
||||
SendAccessDomainCredentials,
|
||||
SendAccessToken,
|
||||
SendHashedPasswordB64,
|
||||
sendIdInvalid,
|
||||
SendOtp,
|
||||
SendTokenService,
|
||||
} from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
|
||||
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
|
||||
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { SendAccessEmailComponent } from "./send-access-email.component";
|
||||
import { SendAccessPasswordComponent } from "./send-access-password.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-send-auth",
|
||||
templateUrl: "send-auth.component.html",
|
||||
imports: [SendAccessPasswordComponent, SharedModule],
|
||||
imports: [SendAccessPasswordComponent, SendAccessEmailComponent, SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendAuthComponent {
|
||||
readonly id = input.required<string>();
|
||||
readonly key = input.required<string>();
|
||||
export class SendAuthComponent implements OnInit {
|
||||
protected readonly id = input.required<string>();
|
||||
protected readonly key = input.required<string>();
|
||||
|
||||
accessGranted = output<{
|
||||
response: SendAccessResponse;
|
||||
request: SendAccessRequest;
|
||||
protected accessGranted = output<{
|
||||
response?: SendAccessResponse;
|
||||
request?: SendAccessRequest;
|
||||
accessToken?: SendAccessToken;
|
||||
}>();
|
||||
|
||||
loading = false;
|
||||
error = false;
|
||||
unavailable = false;
|
||||
password?: string;
|
||||
authType = AuthType;
|
||||
|
||||
private accessRequest!: SendAccessRequest;
|
||||
private expiredAuthAttempts = 0;
|
||||
|
||||
readonly loading = signal<boolean>(false);
|
||||
readonly error = signal<boolean>(false);
|
||||
readonly unavailable = signal<boolean>(false);
|
||||
readonly sendAuthType = signal<AuthType>(AuthType.None);
|
||||
readonly enterOtp = signal<boolean>(false);
|
||||
|
||||
sendAccessForm = this.formBuilder.group<{ password?: string; email?: string; otp?: string }>({});
|
||||
|
||||
constructor(
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private sendApiService: SendApiService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private formBuilder: FormBuilder,
|
||||
private configService: ConfigService,
|
||||
private sendTokenService: SendTokenService,
|
||||
) {}
|
||||
|
||||
async onSubmit(password: string) {
|
||||
this.password = password;
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
this.unavailable = false;
|
||||
ngOnInit() {
|
||||
void this.onSubmit();
|
||||
}
|
||||
|
||||
async onSubmit() {
|
||||
this.loading.set(true);
|
||||
this.unavailable.set(false);
|
||||
this.error.set(false);
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
if (sendEmailOtp) {
|
||||
await this.attemptV2Access();
|
||||
} else {
|
||||
await this.attemptV1Access();
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
private async attemptV1Access() {
|
||||
try {
|
||||
const keyArray = Utils.fromUrlB64ToArray(this.key());
|
||||
this.accessRequest = new SendAccessRequest();
|
||||
|
||||
const passwordHash = await this.cryptoFunctionService.pbkdf2(
|
||||
this.password,
|
||||
keyArray,
|
||||
"sha256",
|
||||
SEND_KDF_ITERATIONS,
|
||||
);
|
||||
this.accessRequest.password = Utils.fromBufferToB64(passwordHash);
|
||||
|
||||
const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest);
|
||||
this.accessGranted.emit({ response: sendResponse, request: this.accessRequest });
|
||||
const accessRequest = new SendAccessRequest();
|
||||
if (this.sendAuthType() === AuthType.Password) {
|
||||
const password = this.sendAccessForm.value.password;
|
||||
if (password == null) {
|
||||
return;
|
||||
}
|
||||
accessRequest.password = await this.getPasswordHashB64(password, this.key());
|
||||
}
|
||||
const sendResponse = await this.sendApiService.postSendAccess(this.id(), accessRequest);
|
||||
this.accessGranted.emit({ request: accessRequest, response: sendResponse });
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse) {
|
||||
if (e.statusCode === 404) {
|
||||
this.unavailable = true;
|
||||
} else if (e.statusCode === 400) {
|
||||
if (e.statusCode === 401) {
|
||||
this.sendAuthType.set(AuthType.Password);
|
||||
} else if (e.statusCode === 404) {
|
||||
this.unavailable.set(true);
|
||||
} else {
|
||||
this.error.set(true);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
this.error = true;
|
||||
}
|
||||
} else {
|
||||
this.error = true;
|
||||
this.error.set(true);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptV2Access(): Promise<void> {
|
||||
let sendAccessCreds: SendAccessDomainCredentials | null = null;
|
||||
if (this.sendAuthType() === AuthType.Email) {
|
||||
const email = this.sendAccessForm.value.email;
|
||||
if (email == null) {
|
||||
return;
|
||||
}
|
||||
if (!this.enterOtp()) {
|
||||
sendAccessCreds = { kind: "email", email };
|
||||
} else {
|
||||
const otp = this.sendAccessForm.value.otp as SendOtp;
|
||||
if (otp == null) {
|
||||
return;
|
||||
}
|
||||
sendAccessCreds = { kind: "email_otp", email, otp };
|
||||
}
|
||||
} else if (this.sendAuthType() === AuthType.Password) {
|
||||
const password = this.sendAccessForm.value.password;
|
||||
if (password == null) {
|
||||
return;
|
||||
}
|
||||
const passwordHashB64 = await this.getPasswordHashB64(password, this.key());
|
||||
sendAccessCreds = { kind: "password", passwordHashB64 };
|
||||
}
|
||||
const response = !sendAccessCreds
|
||||
? await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(this.id()))
|
||||
: await firstValueFrom(this.sendTokenService.getSendAccessToken$(this.id(), sendAccessCreds));
|
||||
if (response instanceof SendAccessToken) {
|
||||
this.expiredAuthAttempts = 0;
|
||||
this.accessGranted.emit({ accessToken: response });
|
||||
} else if (response.kind === "expired") {
|
||||
if (this.expiredAuthAttempts > 2) {
|
||||
return;
|
||||
}
|
||||
this.expiredAuthAttempts++;
|
||||
await this.attemptV2Access();
|
||||
} else if (response.kind === "expected_server") {
|
||||
this.expiredAuthAttempts = 0;
|
||||
if (emailRequired(response.error)) {
|
||||
this.sendAuthType.set(AuthType.Email);
|
||||
} else if (emailAndOtpRequired(response.error)) {
|
||||
this.enterOtp.set(true);
|
||||
} else if (otpInvalid(response.error)) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidVerificationCode"),
|
||||
});
|
||||
} else if (passwordHashB64Required(response.error)) {
|
||||
this.sendAuthType.set(AuthType.Password);
|
||||
} else if (passwordHashB64Invalid(response.error)) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidSendPassword"),
|
||||
});
|
||||
} else if (sendIdInvalid(response.error)) {
|
||||
this.unavailable.set(true);
|
||||
} else {
|
||||
this.error.set(true);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: response.error.error_description ?? "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.expiredAuthAttempts = 0;
|
||||
this.error.set(true);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: response.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getPasswordHashB64(password: string, key: string) {
|
||||
const keyArray = Utils.fromUrlB64ToArray(key);
|
||||
const passwordHash = await this.cryptoFunctionService.pbkdf2(
|
||||
password,
|
||||
keyArray,
|
||||
"sha256",
|
||||
SEND_KDF_ITERATIONS,
|
||||
);
|
||||
return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
<bit-callout *ngIf="hideEmail" type="warning" title="{{ 'warning' | i18n }}">
|
||||
{{ "viewSendHiddenEmailWarning" | i18n }}
|
||||
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
|
||||
"learnMore" | i18n
|
||||
}}</a
|
||||
>.
|
||||
</bit-callout>
|
||||
@if (hideEmail()) {
|
||||
<bit-callout type="warning" title="{{ 'warning' | i18n }}">
|
||||
{{ "viewSendHiddenEmailWarning" | i18n }}
|
||||
<a bitLink href="https://bitwarden.com/help/receive-send/" target="_blank" rel="noreferrer">{{
|
||||
"learnMore" | i18n
|
||||
}}</a>
|
||||
</bit-callout>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="!loading; else spinner">
|
||||
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
</div>
|
||||
<div class="tw-text-main tw-text-center" *ngIf="error">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
<div *ngIf="send && !error && !unavailable">
|
||||
<p class="tw-text-center">
|
||||
<b>{{ send.name }}</b>
|
||||
</p>
|
||||
<hr />
|
||||
<!-- Text -->
|
||||
<ng-container *ngIf="send.type === sendType.Text">
|
||||
<app-send-access-text [send]="send"></app-send-access-text>
|
||||
</ng-container>
|
||||
<!-- File -->
|
||||
<ng-container *ngIf="send.type === sendType.File">
|
||||
<app-send-access-file
|
||||
[send]="send"
|
||||
[decKey]="decKey"
|
||||
[accessRequest]="accessRequest()"
|
||||
></app-send-access-file>
|
||||
</ng-container>
|
||||
<p *ngIf="expirationDate" class="tw-text-center tw-text-muted">
|
||||
Expires: {{ expirationDate | date: "medium" }}
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #spinner>
|
||||
@if (loading()) {
|
||||
<div class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
@@ -44,4 +16,39 @@
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
} @else {
|
||||
@if (unavailable()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (error()) {
|
||||
<div class="tw-text-main tw-text-center">
|
||||
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (send()) {
|
||||
<div>
|
||||
<p class="tw-text-center">
|
||||
<b>{{ send().name }}</b>
|
||||
</p>
|
||||
<hr />
|
||||
@switch (send().type) {
|
||||
@case (sendType.Text) {
|
||||
<app-send-access-text [send]="send()"></app-send-access-text>
|
||||
}
|
||||
@case (sendType.File) {
|
||||
<app-send-access-file
|
||||
[send]="send()"
|
||||
[decKey]="decKey"
|
||||
[accessRequest]="accessRequest()"
|
||||
[accessToken]="accessToken()"
|
||||
></app-send-access-file>
|
||||
}
|
||||
}
|
||||
@if (expirationDate()) {
|
||||
<p class="tw-text-center tw-text-muted">Expires: {{ expirationDate() | date: "medium" }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
|
||||
import { SendAccessToken } from "@bitwarden/common/auth/send-access";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
@@ -34,17 +38,25 @@ import { SendAccessTextComponent } from "./send-access-text.component";
|
||||
export class SendViewComponent implements OnInit {
|
||||
readonly id = input.required<string>();
|
||||
readonly key = input.required<string>();
|
||||
readonly accessToken = input<SendAccessToken | null>(null);
|
||||
readonly sendResponse = input<SendAccessResponse | null>(null);
|
||||
readonly accessRequest = input<SendAccessRequest>(new SendAccessRequest());
|
||||
|
||||
authRequired = output<void>();
|
||||
|
||||
send: SendAccessView | null = null;
|
||||
readonly send = signal<SendAccessView | null>(null);
|
||||
readonly expirationDate = computed<Date | null>(() => this.send()?.expirationDate ?? null);
|
||||
readonly creatorIdentifier = computed<string | null>(
|
||||
() => this.send()?.creatorIdentifier ?? null,
|
||||
);
|
||||
readonly hideEmail = computed<boolean>(
|
||||
() => this.send() != null && this.creatorIdentifier() == null,
|
||||
);
|
||||
readonly loading = signal<boolean>(false);
|
||||
readonly unavailable = signal<boolean>(false);
|
||||
readonly error = signal<boolean>(false);
|
||||
|
||||
sendType = SendType;
|
||||
loading = true;
|
||||
unavailable = false;
|
||||
error = false;
|
||||
hideEmail = false;
|
||||
decKey!: SymmetricCryptoKey;
|
||||
|
||||
constructor(
|
||||
@@ -53,50 +65,48 @@ export class SendViewComponent implements OnInit {
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private layoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
get expirationDate() {
|
||||
if (this.send == null || this.send.expirationDate == null) {
|
||||
return null;
|
||||
}
|
||||
return this.send.expirationDate;
|
||||
}
|
||||
|
||||
get creatorIdentifier() {
|
||||
if (this.send == null || this.send.creatorIdentifier == null) {
|
||||
return null;
|
||||
}
|
||||
return this.send.creatorIdentifier;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.load();
|
||||
ngOnInit() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
private async load() {
|
||||
this.unavailable = false;
|
||||
this.error = false;
|
||||
this.hideEmail = false;
|
||||
this.loading = true;
|
||||
|
||||
let response = this.sendResponse();
|
||||
this.loading.set(true);
|
||||
this.unavailable.set(false);
|
||||
this.error.set(false);
|
||||
|
||||
try {
|
||||
if (!response) {
|
||||
response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest());
|
||||
const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
let response: SendAccessResponse;
|
||||
if (sendEmailOtp) {
|
||||
const accessToken = this.accessToken();
|
||||
if (!accessToken) {
|
||||
this.authRequired.emit();
|
||||
return;
|
||||
}
|
||||
response = await this.sendApiService.postSendAccessV2(accessToken);
|
||||
} else {
|
||||
const sendResponse = this.sendResponse();
|
||||
if (!sendResponse) {
|
||||
this.authRequired.emit();
|
||||
return;
|
||||
}
|
||||
response = sendResponse;
|
||||
}
|
||||
|
||||
const keyArray = Utils.fromUrlB64ToArray(this.key());
|
||||
const sendAccess = new SendAccess(response);
|
||||
this.decKey = await this.keyService.makeSendKey(keyArray);
|
||||
this.send = await sendAccess.decrypt(this.decKey);
|
||||
const decSend = await sendAccess.decrypt(this.decKey);
|
||||
this.send.set(decSend);
|
||||
} catch (e) {
|
||||
this.send.set(null);
|
||||
if (e instanceof ErrorResponse) {
|
||||
if (e.statusCode === 401) {
|
||||
this.authRequired.emit();
|
||||
} else if (e.statusCode === 404) {
|
||||
this.unavailable = true;
|
||||
this.unavailable.set(true);
|
||||
} else if (e.statusCode === 400) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
@@ -104,28 +114,23 @@ export class SendViewComponent implements OnInit {
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
this.error = true;
|
||||
this.error.set(true);
|
||||
}
|
||||
} else {
|
||||
this.error = true;
|
||||
this.error.set(true);
|
||||
}
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.hideEmail =
|
||||
this.creatorIdentifier == null && !this.loading && !this.unavailable && !response;
|
||||
|
||||
this.hideEmail = this.send != null && this.creatorIdentifier == null;
|
||||
|
||||
if (this.creatorIdentifier != null) {
|
||||
const creatorIdentifier = this.creatorIdentifier();
|
||||
if (creatorIdentifier != null) {
|
||||
this.layoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageSubtitle: {
|
||||
key: "sendAccessCreatorIdentifier",
|
||||
placeholders: [this.creatorIdentifier],
|
||||
placeholders: [creatorIdentifier],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<bit-search
|
||||
[(ngModel)]="searchText"
|
||||
[placeholder]="'searchSends' | i18n"
|
||||
(input)="searchTextChanged()"
|
||||
(ngModelChange)="searchTextChanged()"
|
||||
appAutofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<bit-dialog dialogSize="large" disablePadding="false" background="alt">
|
||||
<ng-container bitDialogTitle>
|
||||
<span>{{ dialogTitle() | i18n }}</span>
|
||||
<span>{{ dialogTitle | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container bitDialogContent>
|
||||
<div
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<div class="tw-mb-6 tw-mt-8">
|
||||
<div class="tw-size-[95px] tw-content-center">
|
||||
<bit-icon [icon]="activeSendIcon"></bit-icon>
|
||||
<bit-svg [content]="activeSendIcon"></bit-svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
</h3>
|
||||
|
||||
<p bitTypography="body1" class="tw-mb-6 tw-max-w-sm">
|
||||
{{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }}
|
||||
@let translationKey =
|
||||
send.authType === AuthType.Email
|
||||
? "sendCreatedDescriptionEmail"
|
||||
: send.authType === AuthType.Password
|
||||
? "sendCreatedDescriptionPassword"
|
||||
: "sendCreatedDescriptionV2";
|
||||
{{ translationKey | i18n: formattedExpirationTime }}
|
||||
</p>
|
||||
|
||||
<bit-form-field class="tw-w-full tw-max-w-sm tw-mb-4">
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogModule,
|
||||
I18nMockService,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component";
|
||||
|
||||
describe("SendSuccessDrawerDialogComponent", () => {
|
||||
let fixture: ComponentFixture<SendSuccessDrawerDialogComponent>;
|
||||
let component: SendSuccessDrawerDialogComponent;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
|
||||
let sendView: SendView;
|
||||
|
||||
// Translation Keys
|
||||
const newTextSend = "New Text Send";
|
||||
const newFileSend = "New File Send";
|
||||
const oneHour = "1 hour";
|
||||
const oneDay = "1 day";
|
||||
const sendCreatedSuccessfully = "Send has been created successfully";
|
||||
const sendCreatedDescriptionV2 = "Send ready to share with anyone";
|
||||
const sendCreatedDescriptionEmail = "Email-verified Send ready to share";
|
||||
const sendCreatedDescriptionPassword = "Password-protected Send ready to share";
|
||||
|
||||
beforeEach(async () => {
|
||||
environmentService = mock<EnvironmentService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
toastService = mock<ToastService>();
|
||||
|
||||
sendView = {
|
||||
id: "test-send-id",
|
||||
authType: AuthType.None,
|
||||
deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
type: SendType.Text,
|
||||
accessId: "abc",
|
||||
urlB64Key: "123",
|
||||
} as SendView;
|
||||
|
||||
Object.defineProperty(environmentService, "environment$", {
|
||||
configurable: true,
|
||||
get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })),
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SharedModule, DialogModule, TypographyModule],
|
||||
providers: [
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useValue: sendView,
|
||||
},
|
||||
{ provide: EnvironmentService, useValue: environmentService },
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
newTextSend,
|
||||
newFileSend,
|
||||
sendCreatedSuccessfully,
|
||||
sendCreatedDescriptionEmail,
|
||||
sendCreatedDescriptionPassword,
|
||||
sendCreatedDescriptionV2,
|
||||
sendLink: "Send link",
|
||||
copyLink: "Copy Send Link",
|
||||
close: "Close",
|
||||
oneHour,
|
||||
durationTimeHours: (hours) => `${hours} hours`,
|
||||
oneDay,
|
||||
days: (days) => `${days} days`,
|
||||
loading: "loading",
|
||||
});
|
||||
},
|
||||
},
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendSuccessDrawerDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should have the correct title for text Sends", () => {
|
||||
sendView.type = SendType.Text;
|
||||
fixture.detectChanges();
|
||||
expect(component.dialogTitle).toBe("newTextSend");
|
||||
});
|
||||
|
||||
it("should have the correct title for file Sends", () => {
|
||||
fixture.componentInstance.send.type = SendType.File;
|
||||
fixture.detectChanges();
|
||||
expect(component.dialogTitle).toBe("newFileSend");
|
||||
});
|
||||
|
||||
it("should show the correct message for Sends with an expiration time of one hour from now", () => {
|
||||
sendView.deletionDate = new Date(Date.now() + 1 * 60 * 60 * 1000);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedExpirationTime).toBe(oneHour);
|
||||
});
|
||||
|
||||
it("should show the correct message for Sends with an expiration time more than an hour but less than a day from now", () => {
|
||||
const numHours = 8;
|
||||
sendView.deletionDate = new Date(Date.now() + numHours * 60 * 60 * 1000);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedExpirationTime).toBe(`${numHours} hours`);
|
||||
});
|
||||
|
||||
it("should have the correct title for Sends with an expiration time of one day from now", () => {
|
||||
sendView.deletionDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedExpirationTime).toBe(oneDay);
|
||||
});
|
||||
|
||||
it("should have the correct title for Sends with an expiration time of multiple days from now", () => {
|
||||
const numDays = 3;
|
||||
sendView.deletionDate = new Date(Date.now() + numDays * 24 * 60 * 60 * 1000);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedExpirationTime).toBe(`${numDays} days`);
|
||||
});
|
||||
|
||||
it("should show the correct message for successfully-created Sends with no authentication", () => {
|
||||
sendView.authType = AuthType.None;
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
|
||||
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionV2);
|
||||
});
|
||||
|
||||
it("should show the correct message for successfully-created Sends with password authentication", () => {
|
||||
sendView.authType = AuthType.Password;
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
|
||||
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionPassword);
|
||||
});
|
||||
|
||||
it("should show the correct message for successfully-created Sends with email authentication", () => {
|
||||
sendView.authType = AuthType.Email;
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully);
|
||||
expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionEmail);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core";
|
||||
import { Component, ChangeDetectionStrategy, Inject, signal } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ActiveSendIcon } from "@bitwarden/assets/svg";
|
||||
@@ -6,6 +6,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
@@ -16,13 +17,13 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendSuccessDrawerDialogComponent {
|
||||
readonly AuthType = AuthType;
|
||||
readonly sendLink = signal<string>("");
|
||||
activeSendIcon = ActiveSendIcon;
|
||||
|
||||
// Computed property to get the dialog title based on send type
|
||||
readonly dialogTitle = computed(() => {
|
||||
get dialogTitle(): string {
|
||||
return this.send.type === SendType.Text ? "newTextSend" : "newFileSend";
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) public send: SendView,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ActivatedRoute } from "@angular/router";
|
||||
import { map, Observable, of, tap } from "rxjs";
|
||||
|
||||
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
|
||||
import { ButtonComponent, IconModule } from "@bitwarden/components";
|
||||
import { ButtonComponent, SvgModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua
|
||||
@Component({
|
||||
selector: "vault-browser-extension-prompt",
|
||||
templateUrl: "./browser-extension-prompt.component.html",
|
||||
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent],
|
||||
imports: [CommonModule, I18nPipe, ButtonComponent, SvgModule, ManuallyOpenExtensionComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<p bitTypography="body1" class="tw-mb-0">
|
||||
{{ "openExtensionFromToolbarPart1" | i18n }}
|
||||
<bit-icon
|
||||
[icon]="BitwardenIcon"
|
||||
<bit-svg
|
||||
[content]="BitwardenIcon"
|
||||
class="!tw-inline-block [&>svg]:tw-align-baseline [&>svg]:-tw-mb-[0.25rem]"
|
||||
></bit-icon>
|
||||
></bit-svg>
|
||||
{{ "openExtensionFromToolbarPart2" | i18n }}
|
||||
</p>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Component, ChangeDetectionStrategy } from "@angular/core";
|
||||
|
||||
import { BitwardenIcon } from "@bitwarden/assets/svg";
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
import { SvgModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "vault-manually-open-extension",
|
||||
templateUrl: "./manually-open-extension.component.html",
|
||||
imports: [I18nPipe, IconModule],
|
||||
imports: [I18nPipe, SvgModule],
|
||||
})
|
||||
export class ManuallyOpenExtensionComponent {
|
||||
protected BitwardenIcon = BitwardenIcon;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<section *ngIf="showSuccessUI" class="tw-flex tw-flex-col tw-items-center">
|
||||
<div class="tw-size-[90px]">
|
||||
<bit-icon [icon]="PartyIcon"></bit-icon>
|
||||
<bit-svg [content]="PartyIcon"></bit-svg>
|
||||
</div>
|
||||
<h1 bitTypography="h2" class="tw-mb-6 tw-mt-4 tw-text-center">
|
||||
{{
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
CenterPositionStrategy,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
LinkModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@@ -52,7 +52,7 @@ type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
|
||||
JslibModule,
|
||||
ButtonComponent,
|
||||
LinkModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
RouterModule,
|
||||
AddExtensionVideosComponent,
|
||||
ManuallyOpenExtensionComponent,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ title }}
|
||||
</span>
|
||||
|
||||
@if (isCipherArchived) {
|
||||
@if (isCipherArchived && !params.isAdminConsoleAction) {
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("VaultItemDialogComponent", () => {
|
||||
getFeatureFlag$: () => of(false),
|
||||
},
|
||||
},
|
||||
{ provide: Router, useValue: {} },
|
||||
{ provide: Router, useValue: { navigate: jest.fn() } },
|
||||
{ provide: ActivatedRoute, useValue: {} },
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
@@ -303,6 +303,25 @@ describe("VaultItemDialogComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive badge", () => {
|
||||
it('should show "archived" badge when the item is archived and not an admin console action', () => {
|
||||
component.setTestCipher({ isArchived: true });
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]"));
|
||||
expect(archivedBadge).toBeTruthy();
|
||||
expect(archivedBadge.nativeElement.textContent.trim()).toBe("archived");
|
||||
});
|
||||
|
||||
it('should not show "archived" badge when the item is archived and is an admin console action', () => {
|
||||
component.setTestCipher({ isArchived: true });
|
||||
component.setTestParams({ mode: "view", isAdminConsoleAction: true });
|
||||
fixture.detectChanges();
|
||||
const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]"));
|
||||
expect(archivedBadge).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitButtonText$", () => {
|
||||
it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => {
|
||||
jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false));
|
||||
@@ -337,4 +356,76 @@ describe("VaultItemDialogComponent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeMode", () => {
|
||||
beforeEach(() => {
|
||||
component.setTestCipher({ type: CipherType.Login, id: "cipher-id" });
|
||||
});
|
||||
|
||||
it("refocuses the dialog header", async () => {
|
||||
const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "focusOnHeader");
|
||||
|
||||
await component["changeMode"]("view");
|
||||
|
||||
expect(focusOnHeaderSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("to view", () => {
|
||||
beforeEach(() => {
|
||||
component.setTestParams({ mode: "form" });
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("sets mode to view", async () => {
|
||||
await component["changeMode"]("view");
|
||||
|
||||
expect(component["params"].mode).toBe("view");
|
||||
});
|
||||
|
||||
it("updates the url", async () => {
|
||||
const router = TestBed.inject(Router);
|
||||
|
||||
await component["changeMode"]("view");
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
queryParams: { action: "view", itemId: "cipher-id" },
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("to form", () => {
|
||||
const waitForFormReady = async () => {
|
||||
const changeModePromise = component["changeMode"]("form");
|
||||
|
||||
expect(component["loadForm"]).toBe(true);
|
||||
|
||||
component["onFormReady"]();
|
||||
await changeModePromise;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("waits for form to be ready when switching to form mode", async () => {
|
||||
await waitForFormReady();
|
||||
|
||||
expect(component["params"].mode).toBe("form");
|
||||
});
|
||||
|
||||
it("updates the url", async () => {
|
||||
const router = TestBed.inject(Router);
|
||||
await waitForFormReady();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
queryParams: { action: "edit", itemId: "cipher-id" },
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router } from "@angular/router";
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
ItemModule,
|
||||
ToastService,
|
||||
CenterPositionStrategy,
|
||||
DialogComponent,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
AttachmentDialogCloseResult,
|
||||
@@ -163,14 +164,11 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
* Reference to the dialog content element. Used to scroll to the top of the dialog when switching modes.
|
||||
* @protected
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild("dialogContent")
|
||||
protected dialogContent: ElementRef<HTMLElement>;
|
||||
protected readonly dialogContent = viewChild.required<ElementRef<HTMLElement>>("dialogContent");
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild(CipherFormComponent) cipherFormComponent!: CipherFormComponent;
|
||||
private readonly cipherFormComponent = viewChild.required(CipherFormComponent);
|
||||
|
||||
private readonly dialogComponent = viewChild(DialogComponent);
|
||||
|
||||
/**
|
||||
* Tracks if the cipher was ever modified while the dialog was open. Used to ensure the dialog emits the correct result
|
||||
@@ -536,7 +534,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
updatedCipherView = await this.cipherService.decrypt(updatedCipher, activeUserId);
|
||||
}
|
||||
|
||||
this.cipherFormComponent.patchCipher((currentCipher) => {
|
||||
this.cipherFormComponent().patchCipher((currentCipher) => {
|
||||
currentCipher.attachments = updatedCipherView.attachments;
|
||||
currentCipher.revisionDate = updatedCipherView.revisionDate;
|
||||
|
||||
@@ -574,7 +572,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipherFormComponent.patchCipher((current) => {
|
||||
this.cipherFormComponent().patchCipher((current) => {
|
||||
current.revisionDate = revisionDate;
|
||||
current.archivedDate = archivedDate;
|
||||
return current;
|
||||
@@ -595,7 +593,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemsWereSentToArchive"),
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
@@ -691,7 +689,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
this.params.mode = mode;
|
||||
this.updateTitle();
|
||||
// Scroll to the top of the dialog content when switching modes.
|
||||
this.dialogContent.nativeElement.parentElement.scrollTop = 0;
|
||||
this.dialogContent().nativeElement.parentElement.scrollTop = 0;
|
||||
|
||||
// Refocus on title element, the built-in focus management of the dialog only works for the initial open.
|
||||
this.dialogComponent().focusOnHeader();
|
||||
|
||||
// Update the URL query params to reflect the new mode.
|
||||
await this.router.navigate([], {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
title="{{ 'editItemWithName' | i18n: cipher.name }}"
|
||||
type="button"
|
||||
appStopProp
|
||||
aria-haspopup="true"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{{ cipher.name }}
|
||||
</button>
|
||||
|
||||
@@ -157,7 +157,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
|
||||
// If item is archived always show unarchive button, even if user is not premium
|
||||
protected get showUnArchiveButton() {
|
||||
if (!this.archiveEnabled()) {
|
||||
if (!this.archiveEnabled() || this.viewingOrgVault) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
CenterPositionStrategy,
|
||||
@@ -148,11 +147,16 @@ export class BulkDeleteDialogComponent {
|
||||
}
|
||||
|
||||
private async deleteCiphersAdmin(ciphers: string[]): Promise<any> {
|
||||
const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
if (this.permanent) {
|
||||
return await this.apiService.deleteManyCiphersAdmin(deleteRequest);
|
||||
await this.cipherService.deleteManyWithServer(ciphers, userId, true, this.organization.id);
|
||||
} else {
|
||||
return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest);
|
||||
await this.cipherService.softDeleteManyWithServer(
|
||||
ciphers,
|
||||
userId,
|
||||
true,
|
||||
this.organization.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background-color]="color"
|
||||
appA11yTitle="{{ organizationName }}"
|
||||
appA11yTitle="{{ 'ownerBadgeA11yDescription' | i18n: name }}"
|
||||
routerLink
|
||||
[queryParams]="{ organizationId: organizationIdLink }"
|
||||
queryParamsHandling="merge"
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
EmptyTrash,
|
||||
FavoritesIcon,
|
||||
ItemTypes,
|
||||
Icon,
|
||||
BitSvg,
|
||||
} from "@bitwarden/assets/svg";
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -160,7 +160,7 @@ type EmptyStateType = "trash" | "favorites" | "archive";
|
||||
type EmptyStateItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: Icon;
|
||||
icon: BitSvg;
|
||||
};
|
||||
|
||||
type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
|
||||
@@ -925,6 +925,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
|
||||
cipherId: cipher.id as CipherId,
|
||||
organizationId: cipher.organizationId as OrganizationId,
|
||||
canEditCipher: cipher.edit,
|
||||
});
|
||||
|
||||
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||
@@ -1536,8 +1537,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipherFullView = await this.cipherService.getFullCipherView(cipher);
|
||||
cipherFullView.favorite = !cipherFullView.favorite;
|
||||
const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId);
|
||||
await this.cipherService.updateWithServer(encryptedCipher);
|
||||
await this.cipherService.updateWithServer(cipherFullView, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user