mirror of
https://github.com/bitwarden/browser
synced 2026-01-31 00:33:33 +00:00
Merge branch 'main' into ps/electron-update
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
8
apps/desktop/desktop_native/Cargo.lock
generated
8
apps/desktop/desktop_native/Cargo.lock
generated
@@ -1674,9 +1674,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
version = "0.2.178"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -2876,9 +2876,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a"
|
||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
|
||||
@@ -39,7 +39,7 @@ futures = "=0.3.31"
|
||||
hex = "=0.4.3"
|
||||
homedir = "=0.3.4"
|
||||
interprocess = "=2.2.1"
|
||||
libc = "=0.2.177"
|
||||
libc = "=0.2.178"
|
||||
linux-keyutils = "=0.2.4"
|
||||
memsec = "=0.7.0"
|
||||
napi = "=2.16.17"
|
||||
@@ -53,7 +53,7 @@ rsa = "=0.9.6"
|
||||
russh-cryptovec = "=0.7.3"
|
||||
scopeguard = "=1.2.0"
|
||||
secmem-proc = "=0.3.7"
|
||||
security-framework = "=3.5.0"
|
||||
security-framework = "=3.5.1"
|
||||
security-framework-sys = "=2.15.0"
|
||||
serde = "=1.0.209"
|
||||
serde_json = "=1.0.127"
|
||||
|
||||
@@ -37,6 +37,6 @@ concurrently(
|
||||
{
|
||||
prefix: "name",
|
||||
outputStream: process.stdout,
|
||||
killOthers: ["success", "failure"],
|
||||
killOthersOn: ["success", "failure"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -34,6 +34,6 @@ concurrently(
|
||||
{
|
||||
prefix: "name",
|
||||
outputStream: process.stdout,
|
||||
killOthers: ["success", "failure"],
|
||||
killOthersOn: ["success", "failure"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<bit-dialog #dialog dialogSize="large" background="alt">
|
||||
<span bitDialogTitle>{{ "importData" | i18n }}</span>
|
||||
<ng-container bitDialogContent>
|
||||
<tools-import
|
||||
(formLoading)="this.loading = $event"
|
||||
(formDisabled)="this.disabled = $event"
|
||||
(onSuccessfulImport)="this.onSuccessfulImport($event)"
|
||||
[onImportFromBrowser]="this.onImportFromBrowser"
|
||||
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
|
||||
></tools-import>
|
||||
<div class="tw-relative">
|
||||
<tools-import
|
||||
(formLoading)="this.loading = $event"
|
||||
(formDisabled)="this.disabled = $event"
|
||||
(onSuccessfulImport)="this.onSuccessfulImport($event)"
|
||||
[onImportFromBrowser]="this.onImportFromBrowser"
|
||||
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
|
||||
[class.tw-invisible]="loading"
|
||||
></tools-import>
|
||||
@if (loading) {
|
||||
<div class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x tw-text-primary-600" aria-hidden="true"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
15
apps/desktop/src/scss/migration.scss
Normal file
15
apps/desktop/src/scss/migration.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export class CloudHostedPremiumVNextComponent {
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "standalone"
|
||||
tier?.passwordManager.type === "standalone" && tier.passwordManager.annualPrice
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
@@ -172,7 +172,7 @@ export class CloudHostedPremiumVNextComponent {
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "packaged"
|
||||
tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
|
||||
import { Component, computed, DestroyRef, input, OnInit, output, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { catchError, of } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -32,14 +32,6 @@ export type UpgradeAccountResult = {
|
||||
plan: PersonalSubscriptionPricingTierId | null;
|
||||
};
|
||||
|
||||
type CardDetails = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
price: { amount: number; cadence: SubscriptionCadence };
|
||||
button: { text: string; type: ButtonType };
|
||||
features: string[];
|
||||
};
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -60,8 +52,8 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
planSelected = output<PersonalSubscriptionPricingTierId>();
|
||||
closeClicked = output<UpgradeAccountStatus>();
|
||||
protected readonly loading = signal(true);
|
||||
protected premiumCardDetails!: CardDetails;
|
||||
protected familiesCardDetails!: CardDetails;
|
||||
protected premiumCardDetails!: SubscriptionPricingCardDetails;
|
||||
protected familiesCardDetails!: SubscriptionPricingCardDetails;
|
||||
|
||||
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
|
||||
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
|
||||
@@ -122,14 +114,16 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
private createCardDetails(
|
||||
tier: PersonalSubscriptionPricingTier,
|
||||
buttonType: ButtonType,
|
||||
): CardDetails {
|
||||
): SubscriptionPricingCardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
},
|
||||
price: tier.passwordManager.annualPrice
|
||||
? {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
}
|
||||
: undefined,
|
||||
button: {
|
||||
text: this.i18nService.t(
|
||||
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",
|
||||
|
||||
@@ -200,7 +200,8 @@ export class UpgradePaymentService {
|
||||
}
|
||||
|
||||
private getPasswordManagerSeats(planDetails: PlanDetails): number {
|
||||
return "users" in planDetails.details.passwordManager
|
||||
return "users" in planDetails.details.passwordManager &&
|
||||
planDetails.details.passwordManager.users
|
||||
? planDetails.details.passwordManager.users
|
||||
: 0;
|
||||
}
|
||||
|
||||
@@ -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 }})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,33 +20,35 @@
|
||||
<div
|
||||
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2">
|
||||
<div class="tw-flex tw-items-center tw-justify-between">
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">
|
||||
{{ "upgradeToPremium" | i18n }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Tagline with consistent height (exactly 2 lines) -->
|
||||
<div class="tw-mb-6 tw-h-6">
|
||||
<div class="tw-h-6">
|
||||
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
|
||||
{{ cardDetails.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Section -->
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence | i18n }}
|
||||
</span>
|
||||
@if (cardDetails.price) {
|
||||
<div class="tw-mt-5">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-mb-6 tw-h-12">
|
||||
<div class="tw-my-5 tw-h-12">
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
|
||||
@@ -206,4 +206,39 @@ describe("PremiumUpgradeDialogComponent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("self-hosted environment", () => {
|
||||
it("should handle null price data for self-hosted environment", async () => {
|
||||
const selfHostedPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Advanced features for power users",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: undefined as any, // self-host will have these prices empty
|
||||
annualPricePerAdditionalStorageGB: undefined as any,
|
||||
providedStorageGB: undefined as any,
|
||||
features: [
|
||||
{ key: "feature1", value: "Feature 1" },
|
||||
{ key: "feature2", value: "Feature 2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([selfHostedPremiumTier]),
|
||||
);
|
||||
|
||||
const selfHostedFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
const selfHostedComponent = selfHostedFixture.componentInstance;
|
||||
selfHostedFixture.detectChanges();
|
||||
|
||||
const cardDetails = await firstValueFrom(selfHostedComponent["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.price).toBeUndefined();
|
||||
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,23 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockPremiumTierNoPricingData: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Complete online security",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
features: [
|
||||
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
|
||||
{ key: "secureFileStorage", value: "Secure file storage" },
|
||||
{ key: "emergencyAccess", value: "Emergency access" },
|
||||
{ key: "breachMonitoring", value: "Breach monitoring" },
|
||||
{ key: "andMoreFeatures", value: "And more!" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Upgrade Dialog",
|
||||
component: PremiumUpgradeDialogComponent,
|
||||
@@ -86,11 +103,11 @@ export default {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "upgradeNow":
|
||||
return "Upgrade Now";
|
||||
return "Upgrade now";
|
||||
case "month":
|
||||
return "month";
|
||||
case "upgradeToPremium":
|
||||
return "Upgrade To Premium";
|
||||
return "Upgrade to Premium";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
@@ -116,3 +133,18 @@ export default {
|
||||
|
||||
type Story = StoryObj<PremiumUpgradeDialogComponent>;
|
||||
export const Default: Story = {};
|
||||
|
||||
export const NoPricingData: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: {
|
||||
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,12 +3,12 @@ import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
@@ -16,7 +16,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
@@ -27,14 +26,6 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
type CardDetails = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
price: { amount: number; cadence: SubscriptionCadence };
|
||||
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
|
||||
features: string[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "billing-premium-upgrade-dialog",
|
||||
standalone: true,
|
||||
@@ -51,9 +42,8 @@ type CardDetails = {
|
||||
templateUrl: "./premium-upgrade-dialog.component.html",
|
||||
})
|
||||
export class PremiumUpgradeDialogComponent {
|
||||
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(
|
||||
protected cardDetails$: Observable<SubscriptionPricingCardDetails | null> =
|
||||
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
|
||||
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
|
||||
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
|
||||
catchError((error: unknown) => {
|
||||
@@ -91,14 +81,18 @@ export class PremiumUpgradeDialogComponent {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
|
||||
private mapPremiumTierToCardDetails(
|
||||
tier: PersonalSubscriptionPricingTier,
|
||||
): SubscriptionPricingCardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
},
|
||||
price: tier.passwordManager.annualPrice
|
||||
? {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
}
|
||||
: undefined,
|
||||
button: {
|
||||
text: this.i18nService.t("upgradeNow"),
|
||||
type: "primary",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { ButtonType } from "@bitwarden/components";
|
||||
|
||||
export type SubscriptionPricingCardDetails = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
price?: { amount: number; cadence: SubscriptionCadence };
|
||||
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
|
||||
features: string[];
|
||||
};
|
||||
@@ -1498,7 +1498,13 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useClass: DefaultSubscriptionPricingService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
|
||||
deps: [
|
||||
BillingApiServiceAbstraction,
|
||||
ConfigService,
|
||||
I18nServiceAbstraction,
|
||||
LogService,
|
||||
EnvironmentService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
|
||||
@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
const mockFamiliesPlan = {
|
||||
type: PlanType.FamiliesAnnually2025,
|
||||
@@ -328,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const setupEnvironmentService = (
|
||||
envService: MockProxy<EnvironmentService>,
|
||||
region: Region = Region.US,
|
||||
) => {
|
||||
envService.environment$ = of({
|
||||
getRegion: () => region,
|
||||
isCloud: () => region !== Region.SelfHosted,
|
||||
} as any);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
|
||||
setupEnvironmentService(environmentService);
|
||||
|
||||
service = new DefaultSubscriptionPricingService(
|
||||
billingApiService,
|
||||
configService,
|
||||
i18nService,
|
||||
logService,
|
||||
environmentService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
|
||||
@@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
|
||||
@@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("Premium plan API error");
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -914,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan API response", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response missing the Seat property
|
||||
const malformedResponse = {
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan with invalid price types", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response with price as string instead of number
|
||||
const malformedResponse = {
|
||||
Seat: {
|
||||
StripePriceId: "price_seat",
|
||||
Price: "10", // Should be a number
|
||||
},
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Observable behavior and caching", () => {
|
||||
@@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to avoid conflicts with beforeEach setup
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
|
||||
|
||||
@@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe to the premium pricing tier multiple times
|
||||
@@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to test from scratch
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue({
|
||||
@@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
storage: { price: 999 },
|
||||
} as PremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
// Create a new service instance with the feature flag disabled
|
||||
const newService = new DefaultSubscriptionPricingService(
|
||||
@@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe with feature flag disabled
|
||||
@@ -1071,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Self-hosted environment behavior", () => {
|
||||
it("should not call API for self-hosted environment", () => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
|
||||
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
// Trigger subscriptions by calling the methods
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
|
||||
|
||||
// API should not be called for self-hosted environments
|
||||
expect(getPlansSpy).not.toHaveBeenCalled();
|
||||
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
|
||||
expect(tiers).toHaveLength(2); // Premium and Families
|
||||
|
||||
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
|
||||
expect(premiumTier).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.features).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
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/logging";
|
||||
|
||||
@@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
private configService: ConfigService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -66,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -80,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
|
||||
this.environmentService.environment$.pipe(
|
||||
take(1),
|
||||
switchMap((environment) =>
|
||||
!environment.isCloud()
|
||||
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
|
||||
: from(this.billingApiService.getPlans()),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
|
||||
this.billingApiService.getPremiumPlan(),
|
||||
).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to fetch premium plan from API", error);
|
||||
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> =
|
||||
this.environmentService.environment$.pipe(
|
||||
take(1),
|
||||
switchMap((environment) =>
|
||||
!environment.isCloud()
|
||||
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
|
||||
: from(this.billingApiService.getPremiumPlan()).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to fetch premium plan from API", error);
|
||||
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
||||
}),
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
@@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
provided: premiumPlan.storage.provided,
|
||||
seat: premiumPlan.seat?.price,
|
||||
storage: premiumPlan.storage?.price,
|
||||
provided: premiumPlan.storage?.provided,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
@@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
)!;
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
);
|
||||
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan.PasswordManager.baseSeats,
|
||||
annualPrice: familiesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan?.PasswordManager?.baseSeats,
|
||||
annualPrice: familiesPlan?.PasswordManager?.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
@@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(
|
||||
freePlan?.PasswordManager?.maxCollections,
|
||||
),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
@@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualEnterprisePlan = plans.data.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseAnnually,
|
||||
)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
description: this.i18nService.t("planDescEnterpriseV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
this.featureTranslations.accountRecovery(),
|
||||
this.featureTranslations.selfHostOption(),
|
||||
this.featureTranslations.complimentaryFamiliesPlan(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedUsers(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualEnterprisePlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map(
|
||||
(): BusinessSubscriptionPricingTier => ({
|
||||
id: BusinessSubscriptionPricingTierIds.Custom,
|
||||
name: this.i18nService.t("planNameCustom"),
|
||||
description: this.i18nService.t("planDescCustom"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "custom",
|
||||
features: [
|
||||
this.featureTranslations.strengthenCybersecurity(),
|
||||
this.featureTranslations.boostProductivity(),
|
||||
this.featureTranslations.seamlessIntegration(),
|
||||
],
|
||||
},
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan?.SecretsManager?.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
private enterprise$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualEnterprisePlan = plans.data.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseAnnually,
|
||||
);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
description: this.i18nService.t("planDescEnterpriseV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan?.PasswordManager?.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
this.featureTranslations.accountRecovery(),
|
||||
this.featureTranslations.selfHostOption(),
|
||||
this.featureTranslations.complimentaryFamiliesPlan(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan?.SecretsManager?.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualEnterprisePlan?.SecretsManager?.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedUsers(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualEnterprisePlan?.SecretsManager?.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private custom$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map(
|
||||
(): BusinessSubscriptionPricingTier => ({
|
||||
id: BusinessSubscriptionPricingTierIds.Custom,
|
||||
name: this.i18nService.t("planNameCustom"),
|
||||
description: this.i18nService.t("planDescCustom"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "custom",
|
||||
features: [
|
||||
this.featureTranslations.strengthenCybersecurity(),
|
||||
this.featureTranslations.boostProductivity(),
|
||||
this.featureTranslations.seamlessIntegration(),
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
private featureTranslations = {
|
||||
builtInAuthenticator: () => ({
|
||||
@@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
limitedUsersV2: (users?: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
limitedCollectionsV2: (collections?: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
@@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
projectsIncludedV2: (projects?: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
@@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
includedMachineAccountsV2: (included?: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
|
||||
@@ -27,26 +27,26 @@ type HasFeatures = {
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
annualPricePerAdditionalStorageGB?: number;
|
||||
};
|
||||
|
||||
type HasProvidedStorage = {
|
||||
providedStorageGB: number;
|
||||
providedStorageGB?: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage &
|
||||
HasProvidedStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
users?: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
@@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerUser?: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
@@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
annualPricePerUser?: number;
|
||||
annualPricePerAdditionalServiceAccount?: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -1656,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);
|
||||
}
|
||||
|
||||
@@ -1829,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,
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -121,12 +121,12 @@
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"@yao-pkg/pkg": "6.5.1",
|
||||
"angular-eslint": "20.7.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"autoprefixer": "10.4.22",
|
||||
"axe-playwright": "2.2.2",
|
||||
"babel-loader": "9.2.1",
|
||||
"base64-loader": "1.0.0",
|
||||
"browserslist": "4.28.1",
|
||||
"chromatic": "13.3.1",
|
||||
"chromatic": "13.3.4",
|
||||
"concurrently": "9.2.0",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -1042,6 +1042,44 @@
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.24.4",
|
||||
"caniuse-lite": "^1.0.30001702",
|
||||
"fraction.js": "^4.3.7",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/babel-loader": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz",
|
||||
@@ -14120,15 +14158,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
|
||||
"integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==",
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
@@ -16477,9 +16515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
||||
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -16496,9 +16534,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.24.4",
|
||||
"caniuse-lite": "^1.0.30001702",
|
||||
"fraction.js": "^4.3.7",
|
||||
"browserslist": "^4.27.0",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
"fraction.js": "^5.3.4",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -16513,6 +16551,19 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer/node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -17902,9 +17953,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromatic": {
|
||||
"version": "13.3.1",
|
||||
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.1.tgz",
|
||||
"integrity": "sha512-qJ/el70Wo7jFgiXPpuukqxCEc7IKiH/e8MjTzIF9uKw+3XZ6GghOTTLC7lGfeZtosiQBMkRlYet77tC4KKHUng==",
|
||||
"version": "13.3.4",
|
||||
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.4.tgz",
|
||||
"integrity": "sha512-TR5rvyH0ESXobBB3bV8jc87AEAFQC7/n+Eb4XWhJz6hW3YNxIQPVjcbgLv+a4oKHEl1dUBueWSoIQsOVGTd+RQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -19174,9 +19225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cwd": {
|
||||
@@ -22562,6 +22613,7 @@
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
|
||||
@@ -83,12 +83,12 @@
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"@yao-pkg/pkg": "6.5.1",
|
||||
"angular-eslint": "20.7.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"autoprefixer": "10.4.22",
|
||||
"axe-playwright": "2.2.2",
|
||||
"babel-loader": "9.2.1",
|
||||
"base64-loader": "1.0.0",
|
||||
"browserslist": "4.28.1",
|
||||
"chromatic": "13.3.1",
|
||||
"chromatic": "13.3.4",
|
||||
"concurrently": "9.2.0",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user