1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-16 08:34:39 +00:00

Merge branch 'main' into dev/kreynolds/tunnel_proto_v2

This commit is contained in:
Katherine Reynolds
2025-12-08 11:10:44 -08:00
69 changed files with 1781 additions and 957 deletions

View File

@@ -1457,6 +1457,15 @@
"attachmentSaved": {
"message": "Attachment saved"
},
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"file": {
"message": "File"
},

View File

@@ -1,4 +1,4 @@
import { Component, Input } from "@angular/core";
import { Component, input, ChangeDetectionStrategy } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute, Router } from "@angular/router";
@@ -25,31 +25,23 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach
import { AttachmentsV2Component } from "./attachments-v2.component";
// 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: "popup-header",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupHeaderComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() backAction: () => void;
readonly pageTitle = input<string>();
readonly backAction = input<() => void>();
}
// 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: "popup-footer",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupFooterComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
readonly pageTitle = input<string>();
}
describe("AttachmentsV2Component", () => {
@@ -120,7 +112,7 @@ describe("AttachmentsV2Component", () => {
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
.componentInstance;
expect(cipherAttachment.submitBtn).toEqual(submitBtn);
expect(cipherAttachment.submitBtn()).toEqual(submitBtn);
});
it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => {

View File

@@ -186,15 +186,15 @@ export class EditCommand {
return Response.notFound();
}
let folderView = await folder.decrypt();
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
let folderView = await folder.decrypt(userKey);
folderView = FolderExport.toView(req, folderView);
const userKey = await this.keyService.getUserKey(activeUserId);
const encFolder = await this.folderService.encrypt(folderView, userKey);
try {
const folder = await this.folderApiService.save(encFolder, activeUserId);
const updatedFolder = new Folder(folder);
const decFolder = await updatedFolder.decrypt();
const decFolder = await updatedFolder.decrypt(userKey);
const res = new FolderResponse(decFolder);
return Response.success(res);
} catch (e) {

View File

@@ -417,10 +417,11 @@ export class GetCommand extends DownloadCommand {
private async getFolder(id: string) {
let decFolder: FolderView = null;
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
if (Utils.isGuid(id)) {
const folder = await this.folderService.getFromState(id, activeUserId);
if (folder != null) {
decFolder = await folder.decrypt();
decFolder = await folder.decrypt(userKey);
}
} else if (id.trim() !== "") {
let folders = await this.folderService.getAllDecryptedFromState(activeUserId);

View File

@@ -3,6 +3,8 @@ import * as sdk from "@bitwarden/sdk-internal";
export class CliSdkLoadService extends SdkLoadService {
async load(): Promise<void> {
// CLI uses stdout for user interaction / automations so we cannot log info / debug here.
SdkLoadService.logLevel = sdk.LogLevel.Error;
const module = await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm");
(sdk as any).init(module);
}

View File

@@ -181,12 +181,12 @@ export class CreateCommand {
private async createFolder(req: FolderExport) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const userKey = await this.keyService.getUserKey(activeUserId);
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
const folder = await this.folderService.encrypt(FolderExport.toView(req), userKey);
try {
const folderData = await this.folderApiService.save(folder, activeUserId);
const newFolder = new Folder(folderData);
const decFolder = await newFolder.decrypt();
const decFolder = await newFolder.decrypt(userKey);
const res = new FolderResponse(decFolder);
return Response.success(res);
} catch (e) {

View File

@@ -1,4 +1,4 @@
<bit-layout>
<bit-layout class="!tw-h-full">
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>

View File

@@ -46,7 +46,9 @@
</div>
<div class="box-content-row" appBoxRow *ngIf="editMode && type === sendType.File">
<label for="file">{{ "file" | i18n }}</label>
<div class="row-main">{{ send.file.fileName }} ({{ send.file.sizeName }})</div>
<div class="row-main tw-text-wrap tw-break-all">
{{ send.file.fileName }} ({{ send.file.sizeName }})
</div>
</div>
<div class="box-content-row" appBoxRow *ngIf="type === sendType.Text">
<label for="text">{{ "text" | i18n }}</label>

View File

@@ -448,10 +448,10 @@ export class DesktopAutofillService implements OnDestroy {
function normalizePosition(position: { x: number; y: number }): { x: number; y: number } {
// Add 100 pixels to the x-coordinate to offset the native OS dialog positioning.
const xPostionOffset = 100;
const xPositionOffset = 100;
return {
x: Math.round(position.x + xPostionOffset),
x: Math.round(position.x + xPositionOffset),
y: Math.round(position.y),
};
}

View File

@@ -708,6 +708,15 @@
"addAttachment": {
"message": "Add attachment"
},
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"maxFileSizeSansPunctuation": {
"message": "Maximum file size is 500 MB"
},

View File

@@ -0,0 +1,15 @@
/**
* Desktop UI Migration
*
* These are temporary styles during the desktop ui migration.
**/
/**
* This removes any padding applied by the bit-layout to content.
* This should be revisited once the table is migrated, and again once drawers are migrated.
**/
bit-layout {
#main-content {
padding: 0 0 0 0;
}
}

View File

@@ -15,5 +15,6 @@
@import "left-nav.scss";
@import "loading.scss";
@import "plugins.scss";
@import "migration.scss";
@import "../../../../libs/angular/src/scss/icons.scss";
@import "../../../../libs/components/src/multi-select/scss/bw.theme";

View File

@@ -0,0 +1,130 @@
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,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { PeopleTableDataSource } from "./people-table-data-source";
interface MockUser {
id: string;
name: string;
email: string;
status: OrganizationUserStatusType;
checked?: boolean;
}
class TestPeopleTableDataSource extends PeopleTableDataSource<any> {
protected statusType = OrganizationUserStatusType;
}
describe("PeopleTableDataSource", () => {
let dataSource: TestPeopleTableDataSource;
const createMockUser = (id: string, checked: boolean = false): MockUser => ({
id,
name: `User ${id}`,
email: `user${id}@example.com`,
status: OrganizationUserStatusType.Confirmed,
checked,
});
const createMockUsers = (count: number, checked: boolean = false): MockUser[] => {
return Array.from({ length: count }, (_, i) => createMockUser(`${i + 1}`, checked));
};
beforeEach(() => {
const featureFlagSubject = new ReplaySubject<boolean>(1);
featureFlagSubject.next(false);
const environmentSubject = new ReplaySubject<Environment>(1);
environmentSubject.next({
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 },
],
});
dataSource = TestBed.runInInjectionContext(
() => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService),
);
});
describe("limitAndUncheckExcess", () => {
it("should return all users when under limit", () => {
const users = createMockUsers(10, true);
dataSource.data = users;
const result = dataSource.limitAndUncheckExcess(users, 500);
expect(result).toHaveLength(10);
expect(result).toEqual(users);
expect(users.every((u) => u.checked)).toBe(true);
});
it("should limit users and uncheck excess", () => {
const users = createMockUsers(600, true);
dataSource.data = users;
const result = dataSource.limitAndUncheckExcess(users, 500);
expect(result).toHaveLength(500);
expect(result).toEqual(users.slice(0, 500));
expect(users.slice(0, 500).every((u) => u.checked)).toBe(true);
expect(users.slice(500).every((u) => u.checked)).toBe(false);
});
it("should only affect users in the provided array", () => {
const allUsers = createMockUsers(1000, true);
dataSource.data = allUsers;
// Pass only a subset (simulates filtering by status)
const subset = allUsers.slice(0, 600);
const result = dataSource.limitAndUncheckExcess(subset, 500);
expect(result).toHaveLength(500);
expect(subset.slice(0, 500).every((u) => u.checked)).toBe(true);
expect(subset.slice(500).every((u) => u.checked)).toBe(false);
// Users outside subset remain checked
expect(allUsers.slice(600).every((u) => u.checked)).toBe(true);
});
});
describe("status counts", () => {
it("should correctly count users by status", () => {
const users: MockUser[] = [
{ ...createMockUser("1"), status: OrganizationUserStatusType.Invited },
{ ...createMockUser("2"), status: OrganizationUserStatusType.Invited },
{ ...createMockUser("3"), status: OrganizationUserStatusType.Accepted },
{ ...createMockUser("4"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("5"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("6"), status: OrganizationUserStatusType.Confirmed },
{ ...createMockUser("7"), status: OrganizationUserStatusType.Revoked },
];
dataSource.data = users;
expect(dataSource.invitedUserCount).toBe(2);
expect(dataSource.acceptedUserCount).toBe(1);
expect(dataSource.confirmedUserCount).toBe(3);
expect(dataSource.revokedUserCount).toBe(1);
expect(dataSource.activeUserCount).toBe(6); // All except revoked
});
});
});

View File

@@ -1,14 +1,30 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { computed, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";
import {
OrganizationUserStatusType,
ProviderUserStatusType,
} from "@bitwarden/common/admin-console/enums";
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";
import { StatusType, UserViewTypes } from "./base-members.component";
const MaxCheckedCount = 500;
/**
* Default maximum for most bulk operations (confirm, remove, delete, etc.)
*/
export const MaxCheckedCount = 500;
/**
* Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud
* feature flag is enabled on cloud environments.
*/
export const CloudBulkReinviteLimit = 4000;
/**
* Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked).
@@ -56,6 +72,20 @@ 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) */
readonly isIncreasedBulkLimitEnabled: Signal<boolean>;
constructor(configService: ConfigService, environmentService: EnvironmentService) {
super();
const featureFlagEnabled = toSignal(
configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
);
const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud())));
this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud());
}
override set data(data: T[]) {
super.data = data;
@@ -89,6 +119,14 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
return this.data.filter((u) => (u as any).checked);
}
/**
* Gets checked users in the order they appear in the filtered/sorted table view.
* Use this when enforcing limits to ensure visual consistency (top N visible rows stay checked).
*/
getCheckedUsersInVisibleOrder() {
return this.filteredData.filter((u) => (u as any).checked);
}
/**
* Check all filtered users (i.e. those rows that are currently visible)
* @param select check the filtered users (true) or uncheck the filtered users (false)
@@ -101,8 +139,13 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
const filteredUsers = this.filteredData;
const selectCount =
filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length;
// When the increased bulk limit feature is enabled, allow checking all users.
// Individual bulk operations will enforce their specific limits.
// When disabled, enforce the legacy limit at check time.
const selectCount = this.isIncreasedBulkLimitEnabled()
? filteredUsers.length
: Math.min(filteredUsers.length, MaxCheckedCount);
for (let i = 0; i < selectCount; i++) {
this.checkUser(filteredUsers[i], select);
}
@@ -132,4 +175,41 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
this.data = updatedData;
}
}
/**
* Limits an array of users and unchecks those beyond the limit.
* Returns the limited array.
*
* @param users The array of users to limit
* @param limit The maximum number of users to keep
* @returns The users array limited to the specified count
*/
limitAndUncheckExcess(users: T[], limit: number): T[] {
if (users.length <= limit) {
return users;
}
// Uncheck users beyond the limit
users.slice(limit).forEach((user) => this.checkUser(user, false));
return users.slice(0, limit);
}
/**
* Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag.
*
* 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)
* @returns The checked users array
*/
getCheckedUsersWithLimit(limit: number): T[] {
if (this.isIncreasedBulkLimitEnabled()) {
const allUsers = this.getCheckedUsersInVisibleOrder();
return this.limitAndUncheckExcess(allUsers, limit);
} else {
return this.getCheckedUsers();
}
}
}

View File

@@ -33,6 +33,8 @@ 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";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -44,7 +46,11 @@ import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/membe
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
PeopleTableDataSource,
} from "../../common/people-table-data-source";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
@@ -70,7 +76,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
protected dataSource = new MembersTableDataSource();
protected dataSource: MembersTableDataSource;
readonly organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
@@ -113,6 +119,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
@@ -126,6 +134,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
const organization$ = this.route.params.pipe(
concatMap((params) =>
this.userId$.pipe(
@@ -356,10 +366,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkRemoveDialog(
organization,
this.dataSource.getCheckedUsers(),
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
this.organizationMetadataService.refreshMetadataCache();
await this.load(organization);
}
@@ -369,10 +378,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkDeleteDialog(
organization,
this.dataSource.getCheckedUsers(),
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
await this.load(organization);
}
@@ -389,11 +397,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkRestoreRevokeDialog(
organization,
this.dataSource.getCheckedUsers(),
isRevoking,
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
await this.load(organization);
}
@@ -402,8 +408,28 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
const users = this.dataSource.getCheckedUsers();
const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
let users: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
}
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
let filteredUsers: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
filteredUsers = this.dataSource.limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
} else {
filteredUsers = allInvitedUsers;
}
if (filteredUsers.length <= 0) {
this.toastService.showToast({
@@ -424,13 +450,37 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
throw new Error();
}
// Bulk Status component open
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
}
} catch (e) {
this.validationService.showError(e);
}
@@ -442,15 +492,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
await this.memberDialogManager.openBulkConfirmDialog(
organization,
this.dataSource.getCheckedUsers(),
);
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
await this.load(organization);
}
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsers();
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);

View File

@@ -1,4 +1,4 @@
<p>{{ 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 }})

View File

@@ -5173,6 +5173,15 @@
"message": "Fix",
"description": "This is a verb. ex. 'Fix The Car'"
},
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"oldAttachmentsNeedFixDesc": {
"message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key."
},
@@ -6457,6 +6466,32 @@
"bulkReinviteMessage": {
"message": "Reinvited successfully"
},
"bulkReinviteSuccessToast": {
"message": "$COUNT$ users re-invited",
"placeholders": {
"count": {
"content": "$1",
"example": "12"
}
}
},
"bulkReinviteLimitedSuccessToast": {
"message": "$LIMIT$ of $SELECTEDCOUNT$ users re-invited. $EXCLUDEDCOUNT$ were not invited due to the $LIMIT$ invite limit.",
"placeholders": {
"limit": {
"content": "$1",
"example": "4,000"
},
"selectedCount": {
"content": "$2",
"example": "4,005"
},
"excludedCount": {
"content": "$3",
"example": "5"
}
}
},
"bulkRemovedMessage": {
"message": "Removed successfully"
},

View File

@@ -19,6 +19,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.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";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -27,6 +29,8 @@ import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
peopleFilter,
PeopleTableDataSource,
} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
@@ -56,7 +60,7 @@ class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
})
export class MembersComponent extends BaseMembersComponent<ProviderUser> {
accessEvents = false;
dataSource = new MembersTableDataSource();
dataSource: MembersTableDataSource;
loading = true;
providerId: string;
rowHeight = 70;
@@ -81,6 +85,8 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
private providerService: ProviderService,
private router: Router,
private accountService: AccountService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
@@ -94,6 +100,8 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
combineLatest([
this.activatedRoute.parent.params,
this.activatedRoute.queryParams.pipe(first()),
@@ -134,10 +142,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
users: this.dataSource.getCheckedUsers(),
users: users,
},
});
@@ -150,10 +160,28 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
return;
}
const checkedUsers = this.dataSource.getCheckedUsers();
const checkedInvitedUsers = checkedUsers.filter(
(user) => user.status === ProviderUserStatusType.Invited,
);
let users: ProviderUser[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
}
const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited);
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
let checkedInvitedUsers: ProviderUser[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
checkedInvitedUsers = this.dataSource.limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
} else {
checkedInvitedUsers = allInvitedUsers;
}
if (checkedInvitedUsers.length <= 0) {
this.toastService.showToast({
@@ -165,20 +193,50 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
}
try {
const request = this.apiService.postManyProviderUserReinvite(
this.providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
await this.apiService.postManyProviderUserReinvite(
this.providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
const dialogRef = BulkStatusComponent.open(this.dialogService, {
data: {
users: checkedUsers,
filteredUsers: checkedInvitedUsers,
request,
successfulMessage: this.i18nService.t("bulkReinviteMessage"),
},
});
await lastValueFrom(dialogRef.closed);
const selectedCount = originalInvitedCount;
const invitedCount = checkedInvitedUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
const request = this.apiService.postManyProviderUserReinvite(
this.providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
const dialogRef = BulkStatusComponent.open(this.dialogService, {
data: {
users: users,
filteredUsers: checkedInvitedUsers,
request,
successfulMessage: this.i18nService.t("bulkReinviteMessage"),
},
});
await lastValueFrom(dialogRef.closed);
}
} catch (error) {
this.validationService.showError(error);
}
@@ -193,10 +251,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
users: this.dataSource.getCheckedUsers(),
users: users,
},
});

View File

@@ -2,7 +2,10 @@
// @ts-strict-ignore
import { mock, MockProxy } from "jest-mock-extended";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { KeyService } from "@bitwarden/key-management";
import { EncryptionType } from "../src/platform/enums";
import { Utils } from "../src/platform/misc/utils";
@@ -29,6 +32,7 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
export function mockEnc(s: string): MockProxy<EncString> {
const mocked = mock<EncString>();
mocked.decryptedValue = s;
mocked.decrypt.mockResolvedValue(s);
return mocked;
@@ -77,4 +81,14 @@ export const mockFromSdk = (stub: any) => {
return `${stub}_fromSdk`;
};
export const mockContainerService = () => {
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
encryptService.decryptString.mockImplementation(async (encStr, _key) => {
return encStr.decryptedValue;
});
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
return (window as any).bitwardenContainerService;
};
export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";

View File

@@ -14,6 +14,7 @@ export enum FeatureFlag {
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -97,6 +98,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,

View File

@@ -1,4 +1,4 @@
import { init_sdk } from "@bitwarden/sdk-internal";
import { init_sdk, LogLevel } from "@bitwarden/sdk-internal";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
import type { SdkService } from "./sdk.service";
@@ -10,6 +10,7 @@ export class SdkLoadFailedError extends Error {
}
export abstract class SdkLoadService {
protected static logLevel: LogLevel = LogLevel.Info;
private static markAsReady: () => void;
private static markAsFailed: (error: unknown) => void;
@@ -41,7 +42,7 @@ export abstract class SdkLoadService {
async loadAndInit(): Promise<void> {
try {
await this.load();
init_sdk();
init_sdk(SdkLoadService.logLevel);
SdkLoadService.markAsReady();
} catch (error) {
SdkLoadService.markAsFailed(error);

View File

@@ -73,14 +73,13 @@ export default class Domain {
domain: DomainEncryptableKeys<D>,
viewModel: ViewEncryptableKeys<V>,
props: EncryptableKeys<D, V>[],
orgId: string | null,
key: SymmetricCryptoKey | null = null,
objectContext: string = "No Domain Context",
): Promise<V> {
for (const prop of props) {
viewModel[prop] =
(await domain[prop]?.decrypt(
orgId,
null,
key,
`Property: ${prop as string}; ObjectContext: ${objectContext}`,
)) ?? null;

View File

@@ -1,6 +1,6 @@
import { mock } from "jest-mock-extended";
import { mockEnc } from "../../../../../spec";
import { mockContainerService, mockEnc } from "../../../../../spec";
import { SendType } from "../../enums/send-type";
import { SendAccessResponse } from "../response/send-access.response";
@@ -23,6 +23,8 @@ describe("SendAccess", () => {
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
creatorIdentifier: "creatorIdentifier",
} as SendAccessResponse;
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -54,7 +54,7 @@ export class SendAccess extends Domain {
async decrypt(key: SymmetricCryptoKey): Promise<SendAccessView> {
const model = new SendAccessView(this);
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], null, key);
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], key);
switch (this.type) {
case SendType.File:

View File

@@ -1,4 +1,4 @@
import { mockEnc } from "../../../../../spec";
import { mockContainerService, mockEnc } from "../../../../../spec";
import { SendFileData } from "../data/send-file.data";
import { SendFile } from "./send-file";
@@ -39,6 +39,7 @@ describe("SendFile", () => {
});
it("Decrypt", async () => {
mockContainerService();
const sendFile = new SendFile();
sendFile.id = "id";
sendFile.size = "1100";

View File

@@ -38,7 +38,6 @@ export class SendFile extends Domain {
this,
new SendFileView(this),
["fileName"],
null,
key,
);
}

View File

@@ -1,4 +1,4 @@
import { mockEnc } from "../../../../../spec";
import { mockContainerService, mockEnc } from "../../../../../spec";
import { SendTextData } from "../data/send-text.data";
import { SendText } from "./send-text";
@@ -11,6 +11,8 @@ describe("SendText", () => {
text: "encText",
hidden: false,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -30,13 +30,7 @@ export class SendText extends Domain {
}
decrypt(key: SymmetricCryptoKey): Promise<SendTextView> {
return this.decryptObj<SendText, SendTextView>(
this,
new SendTextView(this),
["text"],
null,
key,
);
return this.decryptObj<SendText, SendTextView>(this, new SendTextView(this), ["text"], key);
}
static fromJSON(obj: Jsonify<SendText>) {

View File

@@ -6,7 +6,7 @@ import { emptyGuid, UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { makeStaticByteArray, mockEnc } from "../../../../../spec";
import { makeStaticByteArray, mockContainerService, mockEnc } from "../../../../../spec";
import { EncryptService } from "../../../../key-management/crypto/abstractions/encrypt.service";
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../../../platform/services/container.service";
@@ -43,6 +43,8 @@ describe("Send", () => {
disabled: false,
hideEmail: true,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -89,7 +89,7 @@ export class Send extends Domain {
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
model.cryptoKey = await keyService.makeSendKey(model.key);
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], model.cryptoKey);
switch (this.type) {
case SendType.File:

View File

@@ -161,6 +161,17 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
userId: UserId,
admin?: boolean,
): Promise<Cipher>;
/**
* Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
* @param cipher - The cipher with old attachments to upgrade
* @param userId - The user ID
* @param attachmentId - If provided, only upgrade the attachment with this ID
*/
abstract upgradeOldCipherAttachments(
cipher: CipherView,
userId: UserId,
attachmentId?: string,
): Promise<CipherView>;
/**
* Save the collections for a cipher with the server
*
@@ -274,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
response: Response,
userId: UserId,
useLegacyDecryption?: boolean,
): Promise<Uint8Array | null>;
): Promise<Uint8Array>;
/**
* Decrypts the full `CipherView` for a given `CipherViewLike`.

View File

@@ -4,12 +4,10 @@ import { mock, MockProxy } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec";
import { makeStaticByteArray, mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../../platform/services/container.service";
import { OrgKey, UserKey } from "../../../types/key";
import { AttachmentData } from "../../models/data/attachment.data";
import { Attachment } from "../../models/domain/attachment";
@@ -70,10 +68,9 @@ describe("Attachment", () => {
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const containerService = mockContainerService();
keyService = containerService.keyService as MockProxy<KeyService>;
encryptService = containerService.encryptService as MockProxy<EncryptService>;
});
it("expected output", async () => {
@@ -85,14 +82,13 @@ describe("Attachment", () => {
attachment.key = mockEnc("key");
attachment.fileName = mockEnc("fileName");
const userKey = new SymmetricCryptoKey(makeStaticByteArray(64));
keyService.getUserKey.mockResolvedValue(userKey as UserKey);
encryptService.decryptFileData.mockResolvedValue(makeStaticByteArray(32));
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
const view = await attachment.decrypt(null);
const userKey = new SymmetricCryptoKey(makeStaticByteArray(64));
const view = await attachment.decrypt(userKey);
expect(view).toEqual({
id: "id",
@@ -116,31 +112,11 @@ describe("Attachment", () => {
it("uses the provided key without depending on KeyService", async () => {
const providedKey = mock<SymmetricCryptoKey>();
await attachment.decrypt(null, "", providedKey);
await attachment.decrypt(providedKey, "");
expect(keyService.getUserKey).not.toHaveBeenCalled();
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, providedKey);
});
it("gets an organization key if required", async () => {
const orgKey = mock<OrgKey>();
keyService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey);
await attachment.decrypt("orgId", "", null);
expect(keyService.getOrgKey).toHaveBeenCalledWith("orgId");
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, orgKey);
});
it("gets the user's decryption key if required", async () => {
const userKey = mock<UserKey>();
keyService.getUserKey.mockResolvedValue(userKey);
await attachment.decrypt(null, "", null);
expect(keyService.getUserKey).toHaveBeenCalled();
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, userKey);
});
});
});

View File

@@ -1,6 +1,5 @@
import { Jsonify } from "type-fest";
import { OrgKey, UserKey } from "@bitwarden/common/types/key";
import { Attachment as SdkAttachment } from "@bitwarden/sdk-internal";
import { EncString } from "../../../key-management/crypto/models/enc-string";
@@ -34,21 +33,19 @@ export class Attachment extends Domain {
}
async decrypt(
orgId: string | undefined,
decryptionKey: SymmetricCryptoKey,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<AttachmentView> {
const view = await this.decryptObj<Attachment, AttachmentView>(
this,
new AttachmentView(this),
["fileName"],
orgId ?? null,
encKey,
decryptionKey,
"DomainType: Attachment; " + context,
);
if (this.key != null) {
view.key = await this.decryptAttachmentKey(orgId, encKey);
view.key = await this.decryptAttachmentKey(decryptionKey);
view.encryptedKey = this.key; // Keep the encrypted key for the view
}
@@ -56,27 +53,15 @@ export class Attachment extends Domain {
}
private async decryptAttachmentKey(
orgId: string | undefined,
encKey?: SymmetricCryptoKey,
decryptionKey: SymmetricCryptoKey,
): Promise<SymmetricCryptoKey | undefined> {
try {
if (this.key == null) {
return undefined;
}
if (encKey == null) {
const key = await this.getKeyForDecryption(orgId);
// If we don't have a key, we can't decrypt
if (key == null) {
return undefined;
}
encKey = key;
}
const encryptService = Utils.getContainerService().getEncryptService();
const decValue = await encryptService.unwrapSymmetricKey(this.key, encKey);
const decValue = await encryptService.unwrapSymmetricKey(this.key, decryptionKey);
return decValue;
} catch (e) {
// eslint-disable-next-line no-console
@@ -85,11 +70,6 @@ export class Attachment extends Domain {
}
}
private async getKeyForDecryption(orgId: string | undefined): Promise<OrgKey | UserKey | null> {
const keyService = Utils.getContainerService().getKeyService();
return orgId != null ? await keyService.getOrgKey(orgId) : await keyService.getUserKey();
}
toAttachmentData(): AttachmentData {
const a = new AttachmentData();
if (this.size != null) {

View File

@@ -1,4 +1,9 @@
import { mockEnc, mockFromJson } from "../../../../spec";
import {
makeSymmetricCryptoKey,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { CardData } from "../../../vault/models/data/card.data";
import { Card } from "../../models/domain/card";
@@ -65,7 +70,10 @@ describe("Card", () => {
card.expYear = mockEnc("expYear");
card.code = mockEnc("code");
const view = await card.decrypt(null);
const userKey = makeSymmetricCryptoKey(64);
mockContainerService();
const view = await card.decrypt(userKey);
expect(view).toEqual({
_brand: "brand",

View File

@@ -31,16 +31,11 @@ export class Card extends Domain {
this.code = conditionalEncString(obj.code);
}
async decrypt(
orgId: string | undefined,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<CardView> {
async decrypt(encKey: SymmetricCryptoKey, context = "No Cipher Context"): Promise<CardView> {
return this.decryptObj<Card, CardView>(
this,
new CardView(),
["cardholderName", "brand", "number", "expMonth", "expYear", "code"],
orgId ?? null,
encKey,
"DomainType: Card; " + context,
);

View File

@@ -2,9 +2,7 @@ import { mock } from "jest-mock-extended";
import { Jsonify } from "type-fest";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { MockProxy } from "@bitwarden/common/platform/spec/mock-deep";
import {
CipherType as SdkCipherType,
UriMatchType,
@@ -14,11 +12,15 @@ import {
EncString as SdkEncString,
} from "@bitwarden/sdk-internal";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
import {
makeStaticByteArray,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec/utils";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
import { ContainerService } from "../../../platform/services/container.service";
import { InitializerKey } from "../../../platform/services/cryptography/initializer-key";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../abstractions/cipher.service";
@@ -39,7 +41,16 @@ import { IdentityView } from "../../models/view/identity.view";
import { LoginView } from "../../models/view/login.view";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
const mockSymmetricKey = new SymmetricCryptoKey(makeStaticByteArray(64));
describe("Cipher DTO", () => {
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
const containerService = mockContainerService();
encryptService = containerService.encryptService;
});
it("Convert from empty CipherData", () => {
const data = new CipherData();
const cipher = new Cipher(data);
@@ -95,13 +106,12 @@ describe("Cipher DTO", () => {
login.decrypt.mockResolvedValue(loginView);
cipher.login = login;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockRejectedValue(new Error("Failed to unwrap key"));
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
cipherService.getKeyForCipherKeyDecryption.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
@@ -317,19 +327,11 @@ describe("Cipher DTO", () => {
login.decrypt.mockResolvedValue(loginView);
cipher.login = login;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",
@@ -445,19 +447,11 @@ describe("Cipher DTO", () => {
cipher.permissions = new CipherPermissionsApi();
cipher.archivedDate = undefined;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",
@@ -591,19 +585,11 @@ describe("Cipher DTO", () => {
card.decrypt.mockResolvedValue(cardView);
cipher.card = card;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",
@@ -761,19 +747,11 @@ describe("Cipher DTO", () => {
identity.decrypt.mockResolvedValue(identityView);
cipher.identity = identity;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",

View File

@@ -1,5 +1,6 @@
import { Jsonify } from "type-fest";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { Cipher as SdkCipher } from "@bitwarden/sdk-internal";
import { EncString } from "../../../key-management/crypto/models/enc-string";
@@ -123,19 +124,22 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
}
}
// We are passing the organizationId into the EncString.decrypt() method here, but because the encKey will always be
// present and so the organizationId will not be used.
// We will refactor the EncString.decrypt() in https://bitwarden.atlassian.net/browse/PM-3762 to remove the dependency on the organizationId.
async decrypt(encKey: SymmetricCryptoKey): Promise<CipherView> {
async decrypt(userKeyOrOrgKey: SymmetricCryptoKey): Promise<CipherView> {
assertNonNullish(userKeyOrOrgKey, "userKeyOrOrgKey", "Cipher decryption");
const model = new CipherView(this);
let bypassValidation = true;
// By default, the user/organization key is used for decryption
let cipherDecryptionKey = userKeyOrOrgKey;
// If there is a cipher key present, unwrap it and use it for decryption
if (this.key != null) {
const encryptService = Utils.getContainerService().getEncryptService();
try {
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, encKey);
encKey = cipherKey;
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, userKeyOrOrgKey);
cipherDecryptionKey = cipherKey;
bypassValidation = false;
} catch {
model.name = "[error: cannot decrypt]";
@@ -144,22 +148,15 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
}
}
await this.decryptObj<Cipher, CipherView>(
this,
model,
["name", "notes"],
this.organizationId ?? null,
encKey,
);
await this.decryptObj<Cipher, CipherView>(this, model, ["name", "notes"], cipherDecryptionKey);
switch (this.type) {
case CipherType.Login:
if (this.login != null) {
model.login = await this.login.decrypt(
this.organizationId,
bypassValidation,
userKeyOrOrgKey,
`Cipher Id: ${this.id}`,
encKey,
);
}
break;
@@ -170,29 +167,17 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
break;
case CipherType.Card:
if (this.card != null) {
model.card = await this.card.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
model.card = await this.card.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
}
break;
case CipherType.Identity:
if (this.identity != null) {
model.identity = await this.identity.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
model.identity = await this.identity.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
}
break;
case CipherType.SshKey:
if (this.sshKey != null) {
model.sshKey = await this.sshKey.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
model.sshKey = await this.sshKey.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
}
break;
default:
@@ -203,9 +188,8 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
const attachments: AttachmentView[] = [];
for (const attachment of this.attachments) {
const decryptedAttachment = await attachment.decrypt(
this.organizationId,
userKeyOrOrgKey,
`Cipher Id: ${this.id}`,
encKey,
);
attachments.push(decryptedAttachment);
}
@@ -215,7 +199,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.fields != null && this.fields.length > 0) {
const fields: FieldView[] = [];
for (const field of this.fields) {
const decryptedField = await field.decrypt(this.organizationId, encKey);
const decryptedField = await field.decrypt(userKeyOrOrgKey);
fields.push(decryptedField);
}
model.fields = fields;
@@ -224,7 +208,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.passwordHistory != null && this.passwordHistory.length > 0) {
const passwordHistory: PasswordHistoryView[] = [];
for (const ph of this.passwordHistory) {
const decryptedPh = await ph.decrypt(this.organizationId, encKey);
const decryptedPh = await ph.decrypt(userKeyOrOrgKey);
passwordHistory.push(decryptedPh);
}
model.passwordHistory = passwordHistory;

View File

@@ -1,4 +1,4 @@
import { mockEnc } from "../../../../spec";
import { makeSymmetricCryptoKey, mockContainerService, mockEnc } from "../../../../spec";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { EncryptionType } from "../../../platform/enums";
import { Fido2CredentialData } from "../data/fido2-credential.data";
@@ -103,7 +103,10 @@ describe("Fido2Credential", () => {
credential.discoverable = mockEnc("true");
credential.creationDate = mockDate;
const credentialView = await credential.decrypt(null);
mockContainerService();
const cipherKey = makeSymmetricCryptoKey(64);
const credentialView = await credential.decrypt(cipherKey);
expect(credentialView).toEqual({
credentialId: "credentialId",

View File

@@ -46,10 +46,7 @@ export class Fido2Credential extends Domain {
this.creationDate = new Date(obj.creationDate);
}
async decrypt(
orgId: string | undefined,
encKey?: SymmetricCryptoKey,
): Promise<Fido2CredentialView> {
async decrypt(decryptionKey: SymmetricCryptoKey): Promise<Fido2CredentialView> {
const view = await this.decryptObj<Fido2Credential, Fido2CredentialView>(
this,
new Fido2CredentialView(),
@@ -65,8 +62,7 @@ export class Fido2Credential extends Domain {
"rpName",
"userDisplayName",
],
orgId ?? null,
encKey,
decryptionKey,
);
const { counter } = await this.decryptObj<
@@ -74,7 +70,7 @@ export class Fido2Credential extends Domain {
{
counter: string;
}
>(this, { counter: "" }, ["counter"], orgId ?? null, encKey);
>(this, { counter: "" }, ["counter"], decryptionKey);
// Counter will end up as NaN if this fails
view.counter = parseInt(counter);
@@ -82,8 +78,7 @@ export class Fido2Credential extends Domain {
this,
{ discoverable: "" },
["discoverable"],
orgId ?? null,
encKey,
decryptionKey,
);
view.discoverable = discoverable === "true";
view.creationDate = this.creationDate;

View File

@@ -6,7 +6,7 @@ import {
IdentityLinkedIdType,
} from "@bitwarden/sdk-internal";
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { CardLinkedId, IdentityLinkedId, LoginLinkedId } from "../../enums";
import { FieldData } from "../../models/data/field.data";
@@ -22,6 +22,7 @@ describe("Field", () => {
value: "encValue",
linkedId: null,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -33,14 +33,8 @@ export class Field extends Domain {
this.value = conditionalEncString(obj.value);
}
decrypt(orgId: string | undefined, encKey?: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj<Field, FieldView>(
this,
new FieldView(this),
["name", "value"],
orgId ?? null,
encKey,
);
decrypt(encKey: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj<Field, FieldView>(this, new FieldView(this), ["name", "value"], encKey);
}
toFieldData(): FieldData {

View File

@@ -1,6 +1,12 @@
import { mock, MockProxy } from "jest-mock-extended";
import { makeEncString, makeSymmetricCryptoKey, mockEnc, mockFromJson } from "../../../../spec";
import {
makeEncString,
makeSymmetricCryptoKey,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { FolderData } from "../../models/data/folder.data";
@@ -15,6 +21,7 @@ describe("Folder", () => {
name: "encName",
revisionDate: "2022-01-31T12:00:00.000Z",
};
mockContainerService();
});
it("Convert", () => {
@@ -33,7 +40,7 @@ describe("Folder", () => {
folder.name = mockEnc("encName");
folder.revisionDate = new Date("2022-01-31T12:00:00.000Z");
const view = await folder.decrypt();
const view = await folder.decrypt(null);
expect(view).toEqual({
id: "id",

View File

@@ -39,8 +39,8 @@ export class Folder extends Domain {
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
}
decrypt(): Promise<FolderView> {
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], null);
decrypt(key: SymmetricCryptoKey): Promise<FolderView> {
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], key);
}
async decryptWithKey(

View File

@@ -1,4 +1,4 @@
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { IdentityData } from "../../models/data/identity.data";
import { Identity } from "../../models/domain/identity";
@@ -27,6 +27,8 @@ describe("Identity", () => {
passportNumber: "encpassportNumber",
licenseNumber: "enclicenseNumber",
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -56,9 +56,8 @@ export class Identity extends Domain {
}
decrypt(
orgId: string | undefined,
encKey: SymmetricCryptoKey,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<IdentityView> {
return this.decryptObj<Identity, IdentityView>(
this,
@@ -83,7 +82,6 @@ export class Identity extends Domain {
"passportNumber",
"licenseNumber",
],
orgId ?? null,
encKey,
"DomainType: Identity; " + context,
);

View File

@@ -1,9 +1,14 @@
import { MockProxy, mock } from "jest-mock-extended";
import { MockProxy } from "jest-mock-extended";
import { Jsonify } from "type-fest";
import { UriMatchType } from "@bitwarden/sdk-internal";
import { mockEnc, mockFromJson } from "../../../../spec";
import {
makeSymmetricCryptoKey,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
@@ -14,6 +19,7 @@ import { LoginUri } from "./login-uri";
describe("LoginUri", () => {
let data: LoginUriData;
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
data = {
@@ -21,6 +27,9 @@ describe("LoginUri", () => {
uriChecksum: "encUriChecksum",
match: UriMatchStrategy.Domain,
};
const containerService = mockContainerService();
encryptService = containerService.getEncryptService();
});
it("Convert from empty", () => {
@@ -83,22 +92,13 @@ describe("LoginUri", () => {
});
describe("validateChecksum", () => {
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
encryptService = mock();
global.bitwardenContainerService = {
getEncryptService: () => encryptService,
getKeyService: () => null,
};
});
it("returns true if checksums match", async () => {
const loginUri = new LoginUri();
loginUri.uriChecksum = mockEnc("checksum");
encryptService.hash.mockResolvedValue("checksum");
const actual = await loginUri.validateChecksum("uri", undefined, undefined);
const key = makeSymmetricCryptoKey(64);
const actual = await loginUri.validateChecksum("uri", key);
expect(actual).toBe(true);
expect(encryptService.hash).toHaveBeenCalledWith("uri", "sha256");
@@ -109,7 +109,7 @@ describe("LoginUri", () => {
loginUri.uriChecksum = mockEnc("checksum");
encryptService.hash.mockResolvedValue("incorrect checksum");
const actual = await loginUri.validateChecksum("uri", undefined, undefined);
const actual = await loginUri.validateChecksum("uri", undefined);
expect(actual).toBe(false);
});

View File

@@ -31,29 +31,27 @@ export class LoginUri extends Domain {
}
decrypt(
orgId: string | undefined,
encKey: SymmetricCryptoKey,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginUriView> {
return this.decryptObj<LoginUri, LoginUriView>(
this,
new LoginUriView(this),
["uri"],
orgId ?? null,
encKey,
context,
);
}
async validateChecksum(clearTextUri: string, orgId?: string, encKey?: SymmetricCryptoKey) {
async validateChecksum(clearTextUri: string, encKey: SymmetricCryptoKey) {
if (this.uriChecksum == null) {
return false;
}
const keyService = Utils.getContainerService().getEncryptService();
const localChecksum = await keyService.hash(clearTextUri, "sha256");
const encryptService = Utils.getContainerService().getEncryptService();
const localChecksum = await encryptService.hash(clearTextUri, "sha256");
const remoteChecksum = await this.uriChecksum.decrypt(orgId ?? null, encKey);
const remoteChecksum = await encryptService.decryptString(this.uriChecksum, encKey);
return remoteChecksum === localChecksum;
}

View File

@@ -1,6 +1,6 @@
import { MockProxy, mock } from "jest-mock-extended";
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
import { LoginData } from "../../models/data/login.data";
@@ -14,6 +14,10 @@ import { Fido2CredentialView } from "../view/fido2-credential.view";
import { Fido2Credential } from "./fido2-credential";
describe("Login DTO", () => {
beforeEach(() => {
mockContainerService();
});
it("Convert from empty LoginData", () => {
const data = new LoginData();
const login = new Login(data);
@@ -107,7 +111,7 @@ describe("Login DTO", () => {
loginUri.validateChecksum.mockResolvedValue(true);
login.uris = [loginUri];
const loginView = await login.decrypt(null, true);
const loginView = await login.decrypt(true, null);
expect(loginView).toEqual(expectedView);
});
@@ -119,7 +123,7 @@ describe("Login DTO", () => {
.mockResolvedValueOnce(true);
login.uris = [loginUri, loginUri, loginUri];
const loginView = await login.decrypt(null, false);
const loginView = await login.decrypt(false, null);
expect(loginView).toEqual(expectedView);
});
});

View File

@@ -44,16 +44,14 @@ export class Login extends Domain {
}
async decrypt(
orgId: string | undefined,
bypassValidation: boolean,
encKey: SymmetricCryptoKey,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginView> {
const view = await this.decryptObj<Login, LoginView>(
this,
new LoginView(this),
["username", "password", "totp"],
orgId ?? null,
encKey,
`DomainType: Login; ${context}`,
);
@@ -66,7 +64,7 @@ export class Login extends Domain {
continue;
}
const uri = await this.uris[i].decrypt(orgId, context, encKey);
const uri = await this.uris[i].decrypt(encKey, context);
const uriString = uri.uri;
if (uriString == null) {
@@ -79,7 +77,7 @@ export class Login extends Domain {
// So we bypass the validation if there's no cipher.key or proceed with the validation and
// Skip the value if it's been tampered with.
const isValidUri =
bypassValidation || (await this.uris[i].validateChecksum(uriString, orgId, encKey));
bypassValidation || (await this.uris[i].validateChecksum(uriString, encKey));
if (isValidUri) {
view.uris.push(uri);
@@ -89,7 +87,7 @@ export class Login extends Domain {
if (this.fido2Credentials != null) {
view.fido2Credentials = await Promise.all(
this.fido2Credentials.map((key) => key.decrypt(orgId, encKey)),
this.fido2Credentials.map((key) => key.decrypt(encKey)),
);
}

View File

@@ -1,4 +1,4 @@
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { PasswordHistoryData } from "../../models/data/password-history.data";
import { Password } from "../../models/domain/password";
@@ -11,6 +11,7 @@ describe("Password", () => {
password: "encPassword",
lastUsedDate: "2022-01-31T12:00:00.000Z",
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -22,12 +22,11 @@ export class Password extends Domain {
this.lastUsedDate = new Date(obj.lastUsedDate);
}
decrypt(orgId: string | undefined, encKey?: SymmetricCryptoKey): Promise<PasswordHistoryView> {
decrypt(encKey: SymmetricCryptoKey): Promise<PasswordHistoryView> {
return this.decryptObj<Password, PasswordHistoryView>(
this,
new PasswordHistoryView(this),
["password"],
orgId ?? null,
encKey,
"DomainType: PasswordHistory",
);

View File

@@ -1,3 +1,4 @@
import { mockContainerService } from "../../../../spec";
import { SecureNoteType } from "../../enums";
import { SecureNoteData } from "../data/secure-note.data";
@@ -10,6 +11,8 @@ describe("SecureNote", () => {
data = {
type: SecureNoteType.Generic,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -1,7 +1,7 @@
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncString as SdkEncString, SshKey as SdkSshKey } from "@bitwarden/sdk-internal";
import { mockEnc } from "../../../../spec";
import { mockContainerService, mockEnc } from "../../../../spec";
import { SshKeyApi } from "../api/ssh-key.api";
import { SshKeyData } from "../data/ssh-key.data";
@@ -18,6 +18,8 @@ describe("Sshkey", () => {
KeyFingerprint: "keyFingerprint",
}),
);
mockContainerService();
});
it("Convert", () => {

View File

@@ -24,16 +24,11 @@ export class SshKey extends Domain {
this.keyFingerprint = new EncString(obj.keyFingerprint);
}
decrypt(
orgId: string | undefined,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<SshKeyView> {
decrypt(encKey: SymmetricCryptoKey, context = "No Cipher Context"): Promise<SshKeyView> {
return this.decryptObj<SshKey, SshKeyView>(
this,
new SshKeyView(),
["privateKey", "publicKey", "keyFingerprint"],
orgId ?? null,
encKey,
"DomainType: SshKey; " + context,
);

View File

@@ -55,7 +55,7 @@ const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
const cipherData: CipherData = {
id: "id",
organizationId: "orgId",
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId,
folderId: "folderId",
edit: true,
viewPassword: true,
@@ -119,6 +119,8 @@ describe("Cipher Service", () => {
beforeEach(() => {
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
encryptService.encryptString.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT)));
keyService.orgKeys$.mockReturnValue(of({ [orgId]: makeSymmetricCryptoKey(32) as OrgKey }));
keyService.userKey$.mockReturnValue(of(makeSymmetricCryptoKey(64) as UserKey));
// Mock i18nService collator
i18nService.collator = {
@@ -181,9 +183,6 @@ describe("Cipher Service", () => {
const testCipher = new Cipher(cipherData);
const expectedRevisionDate = "2022-01-31T12:00:00.000Z";
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
keyService.makeDataEncKey.mockReturnValue(
Promise.resolve([
new SymmetricCryptoKey(new Uint8Array(32)),

View File

@@ -165,7 +165,9 @@ export class CipherService implements CipherServiceAbstraction {
}),
switchMap(async (ciphers) => {
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
await this.setFailedDecryptedCiphers(failures, userId);
void this.setFailedDecryptedCiphers(failures, userId);
// Trigger full decryption and indexing in background
void this.getAllDecrypted(userId);
return decrypted;
}),
tap((decrypted) => {
@@ -1562,10 +1564,15 @@ export class CipherService implements CipherServiceAbstraction {
}
async getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<UserKey | OrgKey> {
return (
(await this.keyService.getOrgKey(cipher.organizationId)) ||
((await this.keyService.getUserKey(userId)) as UserKey)
);
if (cipher.organizationId == null) {
return await firstValueFrom(this.keyService.userKey$(userId));
} else {
return await firstValueFrom(
this.keyService
.orgKeys$(userId)
.pipe(map((orgKeys) => orgKeys[cipher.organizationId as OrganizationId] as OrgKey)),
);
}
}
async setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId) {
@@ -1649,12 +1656,14 @@ export class CipherService implements CipherServiceAbstraction {
const key =
attachment.key != null
? attachment.key
: await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
filterOutNullish(),
map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
),
);
: cipherDomain.organizationId
? await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
filterOutNullish(),
map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
),
)
: await firstValueFrom(this.keyService.userKey$(userId).pipe(filterOutNullish()));
return await this.encryptService.decryptFileData(encBuf, key);
}
@@ -1822,6 +1831,95 @@ export class CipherService implements CipherServiceAbstraction {
}
}
/**
* Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
* @param cipher
* @param userId
* @param attachmentId Optional specific attachment ID to upgrade. If not provided, all old attachments will be upgraded.
*/
async upgradeOldCipherAttachments(
cipher: CipherView,
userId: UserId,
attachmentId?: string,
): Promise<CipherView> {
if (!cipher.hasOldAttachments) {
return cipher;
}
let cipherDomain = await this.get(cipher.id, userId);
for (const attachmentView of cipher.attachments) {
if (
attachmentView.key != null ||
(attachmentId != null && attachmentView.id !== attachmentId)
) {
continue;
}
try {
// 1. Get download URL
const downloadUrl = await this.getAttachmentDownloadUrl(cipher.id, attachmentView);
// 2. Download attachment data
const dataResponse = await this.apiService.nativeFetch(
new Request(downloadUrl, { cache: "no-store" }),
);
if (dataResponse.status !== 200) {
throw new Error(`Failed to download attachment. Status: ${dataResponse.status}`);
}
// 3. Decrypt the attachment
const decryptedBuffer = await this.getDecryptedAttachmentBuffer(
cipher.id as CipherId,
attachmentView,
dataResponse,
userId,
);
// 4. Re-upload with attachment key
cipherDomain = await this.saveAttachmentRawWithServer(
cipherDomain,
attachmentView.fileName,
decryptedBuffer,
userId,
);
// 5. Delete the old attachment
const cipherData = await this.deleteAttachmentWithServer(
cipher.id,
attachmentView.id,
userId,
);
cipherDomain = new Cipher(cipherData);
} catch (e) {
this.logService.error(`Failed to upgrade attachment ${attachmentView.id}`, e);
throw e;
}
}
return await this.decrypt(cipherDomain, userId);
}
private async getAttachmentDownloadUrl(
cipherId: string,
attachmentView: AttachmentView,
): Promise<string> {
try {
const attachmentResponse = await this.apiService.getAttachmentData(
cipherId,
attachmentView.id,
);
return attachmentResponse.url;
} catch (e) {
// Fall back to the attachment's stored URL
if (e instanceof ErrorResponse && e.statusCode === 404 && attachmentView.url) {
return attachmentView.url;
}
throw new Error(`Failed to get download URL for attachment ${attachmentView.id}`);
}
}
private async encryptObjProperty<V extends View, D extends Domain>(
model: V,
obj: D,

View File

@@ -1,6 +1,6 @@
<span class="tw-relative tw-inline-block tw-leading-[0px]">
<span class="tw-inline-block tw-leading-[0px]" [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
<i class="bwi" [ngClass]="iconClass()" aria-hidden="true"></i>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"

View File

@@ -20,7 +20,16 @@ import { SpinnerComponent } from "../spinner";
import { TooltipDirective } from "../tooltip";
import { ariaDisableElement } from "../utils";
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
export const IconButtonTypes = [
"primary",
"danger",
"contrast",
"main",
"muted",
"nav-contrast",
] as const;
export type IconButtonType = (typeof IconButtonTypes)[number];
const focusRing = [
// Workaround for box-shadow with transparent offset issue:
@@ -148,9 +157,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
);
}
get iconClass() {
return [this.icon(), "!tw-m-0"];
}
readonly iconClass = computed(() => [this.icon(), "!tw-m-0"]);
protected readonly disabledAttr = computed(() => {
const disabled = this.disabled() != null && this.disabled() !== false;

View File

@@ -5,7 +5,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { I18nMockService } from "../utils";
import { BitIconButtonComponent } from "./icon-button.component";
import { BitIconButtonComponent, IconButtonTypes } from "./icon-button.component";
export default {
title: "Component Library/Icon Button",
@@ -30,7 +30,7 @@ export default {
},
argTypes: {
buttonType: {
options: ["primary", "secondary", "danger", "unstyled", "contrast", "main", "muted", "light"],
options: IconButtonTypes,
},
},
parameters: {

View File

@@ -6,7 +6,6 @@ import { filter, firstValueFrom } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { Collection, CollectionView } from "@bitwarden/admin-console/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import {
@@ -46,6 +45,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
}
async parse(data: string): Promise<ImportResult> {
const account = await firstValueFrom(this.accountService.activeAccount$);
this.result = new ImportResult();
const results: BitwardenJsonExport = JSON.parse(data);
if (results == null || results.items == null) {
@@ -54,9 +54,9 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
}
if (results.encrypted) {
await this.parseEncrypted(results as any);
await this.parseEncrypted(results as any, account.id);
} else {
await this.parseDecrypted(results as any);
await this.parseDecrypted(results as any, account.id);
}
return this.result;
@@ -64,9 +64,8 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
private async parseEncrypted(
results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport,
userId: UserId,
) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (results.encKeyValidation_DO_NOT_EDIT != null) {
const orgKeys = await firstValueFrom(this.keyService.orgKeys$(userId));
let keyForDecryption: SymmetricCryptoKey = orgKeys?.[this.organizationId];
@@ -84,8 +83,8 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
}
const groupingsMap = this.organization
? await this.parseCollections(userId, results as BitwardenEncryptedOrgJsonExport)
: await this.parseFolders(results as BitwardenEncryptedIndividualJsonExport);
? await this.parseCollections(results as BitwardenEncryptedOrgJsonExport, userId)
: await this.parseFolders(results as BitwardenEncryptedIndividualJsonExport, userId);
for (const c of results.items) {
const cipher = CipherWithIdExport.toDomain(c);
@@ -125,12 +124,11 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
private async parseDecrypted(
results: BitwardenUnEncryptedIndividualJsonExport | BitwardenUnEncryptedOrgJsonExport,
userId: UserId,
) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const groupingsMap = this.organization
? await this.parseCollections(userId, results as BitwardenUnEncryptedOrgJsonExport)
: await this.parseFolders(results as BitwardenUnEncryptedIndividualJsonExport);
? await this.parseCollections(results as BitwardenUnEncryptedOrgJsonExport, userId)
: await this.parseFolders(results as BitwardenUnEncryptedIndividualJsonExport, userId);
results.items.forEach((c) => {
const cipher = CipherWithIdExport.toView(c);
@@ -169,11 +167,14 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
private async parseFolders(
data: BitwardenUnEncryptedIndividualJsonExport | BitwardenEncryptedIndividualJsonExport,
userId: UserId,
): Promise<Map<string, number>> | null {
if (data.folders == null) {
return null;
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
const groupingsMap = new Map<string, number>();
for (const f of data.folders) {
@@ -181,7 +182,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
if (data.encrypted) {
const folder = FolderWithIdExport.toDomain(f);
if (folder != null) {
folderView = await folder.decrypt();
folderView = await folder.decrypt(userKey);
}
} else {
folderView = FolderWithIdExport.toView(f);
@@ -196,8 +197,8 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
}
private async parseCollections(
userId: UserId,
data: BitwardenUnEncryptedOrgJsonExport | BitwardenEncryptedOrgJsonExport,
userId: UserId,
): Promise<Map<string, number>> | null {
if (data.collections == null) {
return null;

View File

@@ -1,7 +1,9 @@
<bit-section [formGroup]="sendFileDetailsForm">
<div *ngIf="config().mode === 'edit'">
<div bitTypography="body2" class="tw-text-muted">{{ "file" | i18n }}</div>
<div data-testid="file-name">{{ originalSendView().file.fileName }}</div>
<div class="tw-text-wrap tw-break-all" data-testid="file-name">
{{ originalSendView().file.fileName }}
</div>
<div data-testid="file-size" class="tw-text-muted">{{ originalSendView().file.sizeName }}</div>
</div>
<bit-form-field *ngIf="config().mode !== 'edit'">

View File

@@ -1,32 +1,57 @@
<h2 class="tw-sr-only" id="attachments">{{ "attachments" | i18n }}</h2>
<ul *ngIf="cipher?.attachments" aria-labelledby="attachments" class="tw-list-none tw-pl-0">
<li *ngFor="let attachment of cipher.attachments">
<bit-item>
<bit-item-content>
<span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
<app-download-attachment
[admin]="admin && organization?.canEditAllCiphers"
[cipher]="cipher"
[attachment]="attachment"
></app-download-attachment>
</bit-item-action>
<bit-item-action>
<app-delete-attachment
[admin]="admin && organization?.canEditAllCiphers"
[cipherId]="cipher.id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
</ng-container>
</bit-item>
</li>
</ul>
@if (cipher()?.attachments; as attachments) {
<ul aria-labelledby="attachments" class="tw-list-none tw-pl-0">
@for (attachment of attachments; track attachment.id) {
<li>
<bit-item>
<bit-item-content>
<span data-testid="file-name" [title]="attachment.fileName">{{
attachment.fileName
}}</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
<i
*ngIf="attachment.key == null"
slot="default-trailing"
class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted"
[appA11yTitle]="'fixEncryptionTooltip' | i18n"
></i>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
@if (attachment.key != null) {
<app-download-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipher]="cipher()"
[attachment]="attachment"
></app-download-attachment>
} @else {
<button
[bitAction]="fixOldAttachment(attachment)"
bitButton
buttonType="primary"
size="small"
type="button"
>
{{ "fixEncryption" | i18n }}
</button>
}
</bit-item-action>
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
</ng-container>
</bit-item>
</li>
}
</ul>
}
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
<bit-card>

View File

@@ -1,7 +1,8 @@
import { Component, Input } from "@angular/core";
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -13,7 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
@@ -26,27 +27,21 @@ import { FakeAccountService, mockAccountServiceWith } from "../../../../../commo
import { CipherAttachmentsComponent } from "./cipher-attachments.component";
import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component";
// 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-download-attachment",
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockDownloadAttachmentComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() attachment: AttachmentView;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() cipher: CipherView;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() admin: boolean = false;
readonly attachment = input<AttachmentView>();
readonly cipher = input<CipherView>();
readonly admin = input<boolean>(false);
}
describe("CipherAttachmentsComponent", () => {
let component: CipherAttachmentsComponent;
let fixture: ComponentFixture<CipherAttachmentsComponent>;
let submitBtnFixture: ComponentFixture<ButtonComponent>;
const showToast = jest.fn();
const cipherView = {
id: "5555-444-3333",
@@ -63,17 +58,21 @@ describe("CipherAttachmentsComponent", () => {
};
const organization = new Organization();
organization.id = "org-123" as OrganizationId;
organization.type = OrganizationUserType.Admin;
organization.allowAdminAccessToAllCollectionItems = true;
const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain);
const cipherServiceDecrypt = jest.fn().mockResolvedValue(cipherView);
const saveAttachmentWithServer = jest.fn().mockResolvedValue(cipherDomain);
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const organizations$ = new BehaviorSubject<Organization[]>([organization]);
beforeEach(async () => {
cipherServiceGet.mockClear();
cipherServiceDecrypt.mockClear().mockResolvedValue(cipherView);
showToast.mockClear();
saveAttachmentWithServer.mockClear().mockResolvedValue(cipherDomain);
@@ -87,7 +86,7 @@ describe("CipherAttachmentsComponent", () => {
get: cipherServiceGet,
saveAttachmentWithServer,
getKeyForCipherKeyDecryption: () => Promise.resolve(null),
decrypt: jest.fn().mockResolvedValue(cipherView),
decrypt: cipherServiceDecrypt,
},
},
{
@@ -110,7 +109,9 @@ describe("CipherAttachmentsComponent", () => {
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
useValue: {
organizations$: () => organizations$.asObservable(),
},
},
],
})
@@ -128,70 +129,67 @@ describe("CipherAttachmentsComponent", () => {
beforeEach(() => {
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
component.cipherId = "5555-444-3333" as CipherId;
component.submitBtn = TestBed.createComponent(ButtonComponent).componentInstance;
submitBtnFixture = TestBed.createComponent(ButtonComponent);
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.detectChanges();
});
/**
* Helper to wait for the async initialization effect to complete
*/
async function waitForInitialization(): Promise<void> {
await fixture.whenStable();
fixture.detectChanges();
}
it("fetches cipherView using `cipherId`", async () => {
await component.ngOnInit();
await waitForInitialization();
expect(cipherServiceGet).toHaveBeenCalledWith("5555-444-3333", mockUserId);
expect(component.cipher).toEqual(cipherView);
});
it("sets testids for automation testing", () => {
it("sets testids for automation testing", async () => {
const attachment = {
id: "1234-5678",
fileName: "test file.txt",
sizeName: "244.2 KB",
} as AttachmentView;
component.cipher.attachments = [attachment];
const cipherWithAttachments = { ...cipherView, attachments: [attachment] };
cipherServiceDecrypt.mockResolvedValue(cipherWithAttachments);
// Create fresh fixture to pick up the mock
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges();
await waitForInitialization();
const fileName = fixture.debugElement.query(By.css('[data-testid="file-name"]'));
const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]'));
expect(fileName.nativeElement.textContent).toEqual(attachment.fileName);
expect(fileName.nativeElement.textContent.trim()).toEqual(attachment.fileName);
expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName);
});
describe("bitSubmit", () => {
beforeEach(() => {
component.submitBtn.disabled.set(undefined);
component.submitBtn.loading.set(undefined);
});
it("updates sets initial state of the submit button", async () => {
await component.ngOnInit();
// Create fresh fixture to properly test initial state
submitBtnFixture = TestBed.createComponent(ButtonComponent);
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
expect(component.submitBtn.disabled()).toBe(true);
});
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges();
it("sets submitBtn loading state", () => {
jest.useFakeTimers();
await waitForInitialization();
component.bitSubmit.loading = true;
jest.runAllTimers();
expect(component.submitBtn.loading()).toBe(true);
component.bitSubmit.loading = false;
expect(component.submitBtn.loading()).toBe(false);
});
it("sets submitBtn disabled state", () => {
component.bitSubmit.disabled = true;
expect(component.submitBtn.disabled()).toBe(true);
component.bitSubmit.disabled = false;
expect(component.submitBtn.disabled()).toBe(false);
expect(submitBtnFixture.componentInstance.disabled()).toBe(true);
});
});
@@ -199,7 +197,7 @@ describe("CipherAttachmentsComponent", () => {
let file: File;
beforeEach(() => {
component.submitBtn.disabled.set(undefined);
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
file = new File([""], "attachment.txt", { type: "text/plain" });
const inputElement = fixture.debugElement.query(By.css("input[type=file]"));
@@ -215,11 +213,11 @@ describe("CipherAttachmentsComponent", () => {
});
it("sets value of `file` control when input changes", () => {
expect(component.attachmentForm.controls.file.value.name).toEqual(file.name);
expect(component.attachmentForm.controls.file.value?.name).toEqual(file.name);
});
it("updates disabled state of submit button", () => {
expect(component.submitBtn.disabled()).toBe(false);
expect(submitBtnFixture.componentInstance.disabled()).toBe(false);
});
});
@@ -250,6 +248,8 @@ describe("CipherAttachmentsComponent", () => {
});
it("shows error toast with server message when saveAttachmentWithServer fails", async () => {
await waitForInitialization();
const file = { size: 100 } as File;
component.attachmentForm.controls.file.setValue(file);
@@ -265,6 +265,8 @@ describe("CipherAttachmentsComponent", () => {
});
it("shows error toast with fallback message when error has no message property", async () => {
await waitForInitialization();
const file = { size: 100 } as File;
component.attachmentForm.controls.file.setValue(file);
@@ -279,6 +281,8 @@ describe("CipherAttachmentsComponent", () => {
});
it("shows error toast with string error message", async () => {
await waitForInitialization();
const file = { size: 100 } as File;
component.attachmentForm.controls.file.setValue(file);
@@ -296,13 +300,27 @@ describe("CipherAttachmentsComponent", () => {
describe("success", () => {
const file = { size: 524287999 } as File;
beforeEach(() => {
async function setupWithOrganization(adminAccess: boolean): Promise<void> {
// Create fresh fixture with organization set before cipherId
organization.allowAdminAccessToAllCollectionItems = adminAccess;
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
submitBtnFixture = TestBed.createComponent(ButtonComponent);
// Set organizationId BEFORE cipherId so the effect picks it up
fixture.componentRef.setInput("organizationId", organization.id);
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges();
await waitForInitialization();
component.attachmentForm.controls.file.setValue(file);
component.organization = organization;
});
}
it("calls `saveAttachmentWithServer` with admin=false when admin permission is false for organization", async () => {
component.organization.allowAdminAccessToAllCollectionItems = false;
await setupWithOrganization(false);
await component.submit();
expect(saveAttachmentWithServer).toHaveBeenCalledWith(
@@ -314,13 +332,16 @@ describe("CipherAttachmentsComponent", () => {
});
it("calls `saveAttachmentWithServer` with admin=true when using admin API", async () => {
component.organization.allowAdminAccessToAllCollectionItems = true;
await setupWithOrganization(true);
await component.submit();
expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file, mockUserId, true);
});
it("resets form and input values", async () => {
await setupWithOrganization(true);
await component.submit();
const fileInput = fixture.debugElement.query(By.css("input[type=file]"));
@@ -330,16 +351,19 @@ describe("CipherAttachmentsComponent", () => {
});
it("shows success toast", async () => {
await setupWithOrganization(true);
await component.submit();
expect(showToast).toHaveBeenCalledWith({
variant: "success",
title: null,
message: "attachmentSaved",
});
});
it('emits "onUploadSuccess"', async () => {
await setupWithOrganization(true);
const emitSpy = jest.spyOn(component.onUploadSuccess, "emit");
await component.submit();
@@ -350,22 +374,36 @@ describe("CipherAttachmentsComponent", () => {
});
describe("removeAttachment", () => {
const attachment = { id: "1234-5678" } as AttachmentView;
const attachment = { id: "1234-5678", fileName: "test.txt" } as AttachmentView;
beforeEach(() => {
component.cipher.attachments = [attachment];
it("removes attachment from cipher", async () => {
// Create a new fixture with cipher that has attachments
const cipherWithAttachments = { ...cipherView, attachments: [attachment] };
cipherServiceDecrypt.mockResolvedValue(cipherWithAttachments);
// Create fresh fixture
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges();
});
it("removes attachment from cipher", () => {
await waitForInitialization();
// Verify attachment is rendered
const attachmentsBefore = fixture.debugElement.queryAll(By.css('[data-testid="file-name"]'));
expect(attachmentsBefore.length).toEqual(1);
const deleteAttachmentComponent = fixture.debugElement.query(
By.directive(DeleteAttachmentComponent),
).componentInstance as DeleteAttachmentComponent;
deleteAttachmentComponent.onDeletionSuccess.emit();
expect(component.cipher.attachments).toEqual([]);
fixture.detectChanges();
// After removal, there should be no attachments displayed
const attachmentItems = fixture.debugElement.queryAll(By.css('[data-testid="file-name"]'));
expect(attachmentItems.length).toEqual(0);
});
});
});

View File

@@ -1,17 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
effect,
inject,
input,
output,
signal,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
@@ -56,11 +54,10 @@ type CipherAttachmentForm = FormGroup<{
file: FormControl<File | null>;
}>;
// 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-cipher-attachments",
templateUrl: "./cipher-attachments.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncActionsModule,
ButtonModule,
@@ -74,70 +71,50 @@ type CipherAttachmentForm = FormGroup<{
DownloadAttachmentComponent,
],
})
export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
export class CipherAttachmentsComponent {
/** `id` associated with the form element */
static attachmentFormID = "attachmentForm";
/** Reference to the file HTMLInputElement */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("fileInput", { read: ElementRef }) private fileInput: ElementRef<HTMLInputElement>;
private readonly fileInput = viewChild("fileInput", { read: ElementRef<HTMLInputElement> });
/** Reference to the BitSubmitDirective */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(BitSubmitDirective) bitSubmit: BitSubmitDirective;
readonly bitSubmit = viewChild(BitSubmitDirective);
/** The `id` of the cipher in context */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) cipherId: CipherId;
readonly cipherId = input.required<CipherId>();
/** The organization ID if this cipher belongs to an organization */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() organizationId?: OrganizationId;
readonly organizationId = input<OrganizationId>();
/** Denotes if the action is occurring from within the admin console */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() admin: boolean = false;
readonly admin = input<boolean>(false);
/** An optional submit button, whose loading/disabled state will be tied to the form state. */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() submitBtn?: ButtonComponent;
readonly submitBtn = input<ButtonComponent>();
/** Emits when a file upload is started */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUploadStarted = new EventEmitter<void>();
readonly onUploadStarted = output<void>();
/** Emits after a file has been successfully uploaded */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUploadSuccess = new EventEmitter<void>();
readonly onUploadSuccess = output<void>();
/** Emits when a file upload fails */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUploadFailed = new EventEmitter<void>();
readonly onUploadFailed = output<void>();
/** Emits after a file has been successfully removed */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onRemoveSuccess = new EventEmitter<void>();
readonly onRemoveSuccess = output<void>();
organization: Organization;
cipher: CipherView;
protected readonly organization = signal<Organization | null>(null);
protected readonly cipher = signal<CipherView | null>(null);
attachmentForm: CipherAttachmentForm = this.formBuilder.group({
file: new FormControl<File>(null, [Validators.required]),
file: new FormControl<File | null>(null, [Validators.required]),
});
private cipherDomain: Cipher;
private activeUserId: UserId;
private destroy$ = inject(DestroyRef);
private cipherDomain: Cipher | null = null;
private activeUserId: UserId | null = null;
private readonly destroyRef = inject(DestroyRef);
constructor(
private cipherService: CipherService,
@@ -150,43 +127,52 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
private organizationService: OrganizationService,
) {
this.attachmentForm.statusChanges.pipe(takeUntilDestroyed()).subscribe((status) => {
if (!this.submitBtn) {
const btn = this.submitBtn();
if (!btn) {
return;
}
this.submitBtn.disabled.set(status !== "VALID");
});
}
async ngOnInit(): Promise<void> {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// Get the organization to check admin permissions
this.organization = await this.getOrganization();
this.cipherDomain = await this.getCipher(this.cipherId);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId);
// Update the initial state of the submit button
if (this.submitBtn) {
this.submitBtn.disabled.set(!this.attachmentForm.valid);
}
}
ngAfterViewInit(): void {
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((loading) => {
if (!this.submitBtn) {
return;
}
this.submitBtn.loading.set(loading);
btn.disabled.set(status !== "VALID");
});
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((disabled) => {
if (!this.submitBtn) {
// Initialize data when cipherId input is available
effect(async () => {
const cipherId = this.cipherId();
if (!cipherId) {
return;
}
this.submitBtn.disabled.set(disabled);
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// Get the organization to check admin permissions
this.organization.set(await this.getOrganization());
this.cipherDomain = await this.getCipher(cipherId);
if (this.cipherDomain && this.activeUserId) {
this.cipher.set(await this.cipherService.decrypt(this.cipherDomain, this.activeUserId));
}
// Update the initial state of the submit button
const btn = this.submitBtn();
if (btn) {
btn.disabled.set(!this.attachmentForm.valid);
}
});
// Sync bitSubmit loading/disabled state with submitBtn
effect(() => {
const bitSubmit = this.bitSubmit();
const btn = this.submitBtn();
if (!bitSubmit || !btn) {
return;
}
bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
btn.loading.set(loading);
});
bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => {
btn.disabled.set(disabled);
});
});
}
@@ -209,7 +195,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
this.onUploadStarted.emit();
const file = this.attachmentForm.value.file;
if (file === null) {
if (file == null) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
@@ -228,24 +214,30 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
return;
}
if (!this.cipherDomain || !this.activeUserId) {
return;
}
try {
this.cipherDomain = await this.cipherService.saveAttachmentWithServer(
this.cipherDomain,
file,
this.activeUserId,
this.organization?.canEditAllCiphers,
this.organization()?.canEditAllCiphers,
);
// re-decrypt the cipher to update the attachments
this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId);
this.cipher.set(await this.cipherService.decrypt(this.cipherDomain, this.activeUserId));
// Reset reactive form and input element
this.fileInput.nativeElement.value = "";
const fileInputEl = this.fileInput();
if (fileInputEl) {
fileInputEl.nativeElement.value = "";
}
this.attachmentForm.controls.file.setValue(null);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("attachmentSaved"),
});
@@ -257,7 +249,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
let errorMessage = this.i18nService.t("unexpectedError");
if (typeof e === "string") {
errorMessage = e;
} else if (e?.message) {
} else if (e instanceof Error && e?.message) {
errorMessage = e.message;
}
@@ -271,10 +263,19 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
/** Removes the attachment from the cipher */
removeAttachment(attachment: AttachmentView) {
const index = this.cipher.attachments.indexOf(attachment);
const currentCipher = this.cipher();
if (!currentCipher?.attachments) {
return;
}
const index = currentCipher.attachments.indexOf(attachment);
if (index > -1) {
this.cipher.attachments.splice(index, 1);
currentCipher.attachments.splice(index, 1);
// Trigger signal update by creating a new reference
this.cipher.set(
Object.assign(Object.create(Object.getPrototypeOf(currentCipher)), currentCipher),
);
}
this.onRemoveSuccess.emit();
@@ -286,7 +287,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
* it will retrieve the cipher using the admin endpoint.
*/
private async getCipher(id: CipherId): Promise<Cipher | null> {
if (id == null) {
if (id == null || !this.activeUserId) {
return null;
}
@@ -294,12 +295,13 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
const localCipher = await this.cipherService.get(id, this.activeUserId);
// If we got the cipher or there's no organization context, return the result
if (localCipher != null || !this.organizationId) {
if (localCipher != null || !this.organizationId()) {
return localCipher;
}
// Only try the admin API if the user has admin permissions
if (this.organization != null && this.organization.canEditAllCiphers) {
const org = this.organization();
if (org != null && org.canEditAllCiphers) {
const cipherResponse = await this.apiService.getCipherAdmin(id);
const cipherData = new CipherData(cipherResponse);
return new Cipher(cipherData);
@@ -312,7 +314,8 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
* Gets the organization for the given organization ID
*/
private async getOrganization(): Promise<Organization | null> {
if (!this.organizationId) {
const orgId = this.organizationId();
if (!orgId || !this.activeUserId) {
return null;
}
@@ -320,6 +323,41 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
this.organizationService.organizations$(this.activeUserId),
);
return organizations.find((o) => o.id === this.organizationId) || null;
return organizations.find((o) => o.id === orgId) || null;
}
protected fixOldAttachment = (attachment: AttachmentView) => {
return async () => {
const cipher = this.cipher();
const userId = this.activeUserId;
if (!attachment.id || !userId || !cipher) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
try {
const updatedCipher = await this.cipherService.upgradeOldCipherAttachments(
cipher,
userId,
attachment.id,
);
this.cipher.set(updatedCipher);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("attachmentUpdated"),
});
this.onUploadSuccess.emit();
} catch {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
}
};
};
}

View File

@@ -1,9 +1,10 @@
<button
*ngIf="!isDecryptionFailure"
[bitAction]="download"
bitIconButton="bwi-download"
buttonType="main"
size="small"
type="button"
[label]="'downloadAttachmentName' | i18n: attachment.fileName"
></button>
@if (!isDecryptionFailure()) {
<button
[bitAction]="download"
bitIconButton="bwi-download"
buttonType="main"
size="small"
type="button"
[label]="'downloadAttachmentName' | i18n: attachment().fileName"
></button>
}

View File

@@ -100,8 +100,8 @@ describe("DownloadAttachmentComponent", () => {
beforeEach(() => {
fixture = TestBed.createComponent(DownloadAttachmentComponent);
component = fixture.componentInstance;
component.attachment = attachment;
component.cipher = cipherView;
fixture.componentRef.setInput("attachment", attachment);
fixture.componentRef.setInput("cipher", cipherView);
fixture.detectChanges();
});
@@ -123,7 +123,8 @@ describe("DownloadAttachmentComponent", () => {
});
it("hides download button when the attachment has decryption failure", () => {
component.attachment.fileName = DECRYPT_ERROR;
const decryptFailureAttachment = { ...attachment, fileName: DECRYPT_ERROR };
fixture.componentRef.setInput("attachment", decryptFailureAttachment);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css("button"))).toBeNull();
@@ -156,7 +157,6 @@ describe("DownloadAttachmentComponent", () => {
expect(showToast).toHaveBeenCalledWith({
message: "errorOccurred",
title: null,
variant: "error",
});
});
@@ -172,7 +172,6 @@ describe("DownloadAttachmentComponent", () => {
expect(showToast).toHaveBeenCalledWith({
message: "errorOccurred",
title: null,
variant: "error",
});
});

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -17,38 +15,27 @@ import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.v
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-download-attachment",
templateUrl: "./download-attachment.component.html",
imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadAttachmentComponent {
/** Attachment to download */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) attachment: AttachmentView;
readonly attachment = input.required<AttachmentView>();
/** The cipher associated with the attachment */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) cipher: CipherView;
readonly cipher = input.required<CipherView>();
// When in view mode, we will want to check for the master password reprompt
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() checkPwReprompt?: boolean = false;
/** When in view mode, we will want to check for the master password reprompt */
readonly checkPwReprompt = input<boolean>(false);
// Required for fetching attachment data when viewed from cipher via emergency access
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() emergencyAccessId?: EmergencyAccessId;
/** Required for fetching attachment data when viewed from cipher via emergency access */
readonly emergencyAccessId = input<EmergencyAccessId>();
/** When owners/admins can mange all items and when accessing from the admin console, use the admin endpoint */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() admin?: boolean = false;
/** When owners/admins can manage all items and when accessing from the admin console, use the admin endpoint */
readonly admin = input<boolean>(false);
constructor(
private i18nService: I18nService,
@@ -59,26 +46,36 @@ export class DownloadAttachmentComponent {
private cipherService: CipherService,
) {}
protected get isDecryptionFailure(): boolean {
return this.attachment.fileName === DECRYPT_ERROR;
}
protected readonly isDecryptionFailure = computed(
() => this.attachment().fileName === DECRYPT_ERROR,
);
/** Download the attachment */
download = async () => {
let url: string;
const attachment = this.attachment();
const cipher = this.cipher();
let url: string | undefined;
if (!attachment.id) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
try {
const attachmentDownloadResponse = this.admin
? await this.apiService.getAttachmentDataAdmin(this.cipher.id, this.attachment.id)
const attachmentDownloadResponse = this.admin()
? await this.apiService.getAttachmentDataAdmin(cipher.id, attachment.id)
: await this.apiService.getAttachmentData(
this.cipher.id,
this.attachment.id,
this.emergencyAccessId,
cipher.id,
attachment.id,
this.emergencyAccessId(),
);
url = attachmentDownloadResponse.url;
} catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = this.attachment.url;
url = attachment.url;
} else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage());
} else {
@@ -86,11 +83,18 @@ export class DownloadAttachmentComponent {
}
}
if (!url) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
return;
@@ -99,26 +103,31 @@ export class DownloadAttachmentComponent {
try {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
if (!userId || !attachment.fileName) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipher.id as CipherId,
this.attachment,
cipher.id as CipherId,
attachment,
response,
userId,
// When the emergency access ID is present, the cipher is being viewed via emergency access.
// Force legacy decryption in these cases.
this.emergencyAccessId ? true : false,
Boolean(this.emergencyAccessId()),
);
this.fileDownloadService.download({
fileName: this.attachment.fileName,
fileName: attachment.fileName,
blobData: decBuf,
});
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}

1018
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,7 @@
"@angular/compiler-cli": "20.3.15",
"@babel/core": "7.28.5",
"@babel/preset-env": "7.28.5",
"@compodoc/compodoc": "1.1.26",
"@compodoc/compodoc": "1.1.32",
"@electron/notarize": "3.0.1",
"@electron/rebuild": "4.0.1",
"@eslint/compat": "2.0.0",
@@ -87,7 +87,7 @@
"axe-playwright": "2.2.2",
"babel-loader": "9.2.1",
"base64-loader": "1.0.0",
"browserslist": "4.28.0",
"browserslist": "4.28.1",
"chromatic": "13.3.1",
"concurrently": "9.2.0",
"copy-webpack-plugin": "13.0.1",
@@ -220,7 +220,7 @@
"parse5": "7.2.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@types/react": "18.3.20"
"@types/react": "18.3.27"
},
"lint-staged": {
"*": "prettier --cache --ignore-unknown --write",