1
0
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:
Stephon Brown
2026-02-03 12:54:27 -05:00
706 changed files with 27076 additions and 8205 deletions

View File

@@ -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),
);
});

View File

@@ -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[] {

View File

@@ -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,

View File

@@ -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,
);
}
}),
),
);
}
}

View File

@@ -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({

View File

@@ -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) =>

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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,

View File

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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -32,6 +32,7 @@
formControlName="minLength"
id="minLength"
[min]="MinPasswordLength"
[max]="MaxPasswordLength"
/>
</bit-form-field>
</div>

View File

@@ -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]);
});
});

View File

@@ -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],

View File

@@ -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,

View File

@@ -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 } },
);
});
});
});

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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;

View File

@@ -56,6 +56,9 @@ export class WebSetInitialPasswordService
);
}
/**
* @deprecated To be removed in PM-28143
*/
override async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,

View File

@@ -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");
});
});
});

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,
);

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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({

View File

@@ -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() +

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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,
},
];
]);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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 }">

View File

@@ -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;

View File

@@ -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,

View File

@@ -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>

View File

@@ -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[]>([]);
}

View File

@@ -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,

View File

@@ -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,
},
});
});
});

View File

@@ -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;
}),

View File

@@ -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,

View File

@@ -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,
],
})

View File

@@ -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({

View File

@@ -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,

View File

@@ -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()"

View File

@@ -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);
}
}

View File

@@ -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>
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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",
});

View File

@@ -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>

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>
}
}

View File

@@ -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();
}
}

View File

@@ -23,7 +23,7 @@
<bit-search
[(ngModel)]="searchText"
[placeholder]="'searchSends' | i18n"
(input)="searchTextChanged()"
(ngModelChange)="searchTextChanged()"
appAutofocus
/>
</div>

View File

@@ -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">

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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">
{{

View File

@@ -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,

View File

@@ -3,7 +3,7 @@
{{ title }}
</span>
@if (isCipherArchived) {
@if (isCipherArchived && !params.isAdminConsoleAction) {
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
}

View File

@@ -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,
});
});
});
});
});

View File

@@ -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([], {

View File

@@ -26,7 +26,7 @@
title="{{ 'editItemWithName' | i18n: cipher.name }}"
type="button"
appStopProp
aria-haspopup="true"
aria-haspopup="dialog"
>
{{ cipher.name }}
</button>

View File

@@ -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;
}

View File

@@ -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,
);
}
}

View File

@@ -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"

View File

@@ -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