Merge branch 'main' of https://github.com/bitwarden/clients into AC-2405-Migrate-scim-component

This commit is contained in:
vinith-kovan 2024-04-17 13:15:48 +05:30
commit 347f27802f
No known key found for this signature in database
GPG Key ID: 9E663946A0AE5089
75 changed files with 1447 additions and 937 deletions

View File

@ -189,12 +189,12 @@ jobs:
path: browser-source/apps/browser/dist/dist-chrome.zip
if-no-files-found: error
# - name: Upload Chrome MV3 artifact
# uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
# with:
# name: dist-chrome-MV3-${{ env._BUILD_NUMBER }}.zip
# path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
# if-no-files-found: error
- name: Upload Chrome MV3 artifact (DO NOT USE FOR PROD)
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: DO-NOT-USE-FOR-PROD-dist-chrome-MV3-${{ env._BUILD_NUMBER }}.zip
path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
if-no-files-found: error
- name: Upload Firefox artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1

View File

@ -172,6 +172,12 @@
"changeMasterPassword": {
"message": "Change master password"
},
"continueToWebApp": {
"message": "Continue to web app?"
},
"changeMasterPasswordOnWebConfirmation": {
"message": "You can change your master password on the Bitwarden web app."
},
"fingerprintPhrase": {
"message": "Fingerprint phrase",
"description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing."
@ -557,12 +563,6 @@
"addedFolder": {
"message": "Folder added"
},
"changeMasterPass": {
"message": "Change master password"
},
"changeMasterPasswordConfirmation": {
"message": "You can change your master password on the bitwarden.com web vault. Do you want to visit the website now?"
},
"twoStepLoginConfirmation": {
"message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?"
},

View File

@ -720,7 +720,7 @@ describe("NotificationBackground", () => {
);
tabSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage").mockImplementation();
editItemSpy = jest.spyOn(notificationBackground as any, "editItem");
setAddEditCipherInfoSpy = jest.spyOn(stateService, "setAddEditCipherInfo");
setAddEditCipherInfoSpy = jest.spyOn(cipherService, "setAddEditCipherInfo");
openAddEditVaultItemPopoutSpy = jest.spyOn(
notificationBackground as any,
"openAddEditVaultItemPopout",

View File

@ -600,14 +600,14 @@ export default class NotificationBackground {
}
/**
* Sets the add/edit cipher info in the state service
* Sets the add/edit cipher info in the cipher service
* and opens the add/edit vault item popout.
*
* @param cipherView - The cipher to edit
* @param senderTab - The tab that the message was sent from
*/
private async editItem(cipherView: CipherView, senderTab: chrome.tabs.Tab) {
await this.stateService.setAddEditCipherInfo({
await this.cipherService.setAddEditCipherInfo({
cipher: cipherView,
collectionIds: cipherView.collectionIds,
});

View File

@ -592,7 +592,7 @@ describe("OverlayBackground", () => {
beforeEach(() => {
sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
jest
.spyOn(overlayBackground["stateService"], "setAddEditCipherInfo")
.spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo")
.mockImplementation();
jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation();
});
@ -600,7 +600,7 @@ describe("OverlayBackground", () => {
it("will not open the add edit popout window if the message does not have a login cipher provided", () => {
sendExtensionRuntimeMessage({ command: "autofillOverlayAddNewVaultItem" }, sender);
expect(overlayBackground["stateService"].setAddEditCipherInfo).not.toHaveBeenCalled();
expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled();
expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled();
});
@ -621,7 +621,7 @@ describe("OverlayBackground", () => {
);
await flushPromises();
expect(overlayBackground["stateService"].setAddEditCipherInfo).toHaveBeenCalled();
expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled();
expect(BrowserApi.sendMessage).toHaveBeenCalledWith(
"inlineAutofillMenuRefreshAddEditCipher",
);

View File

@ -636,7 +636,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
cipherView.type = CipherType.Login;
cipherView.login = loginView;
await this.stateService.setAddEditCipherInfo({
await this.cipherService.setAddEditCipherInfo({
cipher: cipherView,
collectionIds: cipherView.collectionIds,
});

View File

@ -663,12 +663,12 @@ export default class MainBackground {
this.encryptService,
this.cipherFileUploadService,
this.configService,
this.stateProvider,
);
this.folderService = new FolderService(
this.cryptoService,
this.i18nService,
this.cipherService,
this.stateService,
this.stateProvider,
);
this.folderApiService = new FolderApiService(this.folderService, this.apiService);

View File

@ -7,6 +7,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, SimpleDialogOptions } from "@bitwarden/components";
import { BrowserApi } from "../platform/browser/browser-api";
@ -42,6 +43,7 @@ export class AppComponent implements OnInit, OnDestroy {
private stateService: BrowserStateService,
private browserSendStateService: BrowserSendStateService,
private vaultBrowserStateService: VaultBrowserStateService,
private cipherService: CipherService,
private changeDetectorRef: ChangeDetectorRef,
private ngZone: NgZone,
private platformUtilsService: ForegroundPlatformUtilsService,
@ -161,7 +163,7 @@ export class AppComponent implements OnInit, OnDestroy {
await this.clearComponentStates();
}
if (url.startsWith("/tabs/")) {
await this.stateService.setAddEditCipherInfo(null);
await this.cipherService.setAddEditCipherInfo(null);
}
(window as any).previousPopupUrl = url;

View File

@ -153,7 +153,7 @@
*ngIf="showChangeMasterPass"
>
<div class="row-main">{{ "changeMasterPassword" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
<i class="bwi bwi-external-link bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
<button
type="button"

View File

@ -441,9 +441,10 @@ export class SettingsComponent implements OnInit {
async changePassword() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "changeMasterPassword" },
content: { key: "changeMasterPasswordConfirmation" },
title: { key: "continueToWebApp" },
content: { key: "changeMasterPasswordOnWebConfirmation" },
type: "info",
acceptButtonText: { key: "continue" },
});
if (confirmed) {
const env = await firstValueFrom(this.environmentService.environment$);

View File

@ -1,6 +1,7 @@
import { Location } from "@angular/common";
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -9,6 +10,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info";
@ -19,6 +21,7 @@ import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher
export class GeneratorComponent extends BaseGeneratorComponent {
private addEditCipherInfo: AddEditCipherInfo;
private cipherState: CipherView;
private cipherService: CipherService;
constructor(
passwordGenerationService: PasswordGenerationServiceAbstraction,
@ -26,6 +29,7 @@ export class GeneratorComponent extends BaseGeneratorComponent {
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
stateService: StateService,
cipherService: CipherService,
route: ActivatedRoute,
logService: LogService,
private location: Location,
@ -40,10 +44,11 @@ export class GeneratorComponent extends BaseGeneratorComponent {
route,
window,
);
this.cipherService = cipherService;
}
async ngOnInit() {
this.addEditCipherInfo = await this.stateService.getAddEditCipherInfo();
this.addEditCipherInfo = await firstValueFrom(this.cipherService.addEditCipherInfo$);
if (this.addEditCipherInfo != null) {
this.cipherState = this.addEditCipherInfo.cipher;
}
@ -64,7 +69,7 @@ export class GeneratorComponent extends BaseGeneratorComponent {
this.addEditCipherInfo.cipher = this.cipherState;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.stateService.setAddEditCipherInfo(this.addEditCipherInfo);
this.cipherService.setAddEditCipherInfo(this.addEditCipherInfo);
this.close();
}

View File

@ -42,6 +42,7 @@ import {
i18nServiceFactory,
I18nServiceInitOptions,
} from "../../../platform/background/service-factories/i18n-service.factory";
import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory";
import {
stateServiceFactory,
StateServiceInitOptions,
@ -81,6 +82,7 @@ export function cipherServiceFactory(
await encryptServiceFactory(cache, opts),
await cipherFileUploadServiceFactory(cache, opts),
await configServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
),
);
}

View File

@ -14,11 +14,10 @@ import {
i18nServiceFactory,
I18nServiceInitOptions,
} from "../../../platform/background/service-factories/i18n-service.factory";
import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory";
import {
stateServiceFactory as stateServiceFactory,
StateServiceInitOptions,
} from "../../../platform/background/service-factories/state-service.factory";
stateProviderFactory,
StateProviderInitOptions,
} from "../../../platform/background/service-factories/state-provider.factory";
import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory";
@ -28,7 +27,7 @@ export type FolderServiceInitOptions = FolderServiceFactoryOptions &
CryptoServiceInitOptions &
CipherServiceInitOptions &
I18nServiceInitOptions &
StateServiceInitOptions;
StateProviderInitOptions;
export function folderServiceFactory(
cache: { folderService?: AbstractFolderService } & CachedServices,
@ -43,7 +42,6 @@ export function folderServiceFactory(
await cryptoServiceFactory(cache, opts),
await i18nServiceFactory(cache, opts),
await cipherServiceFactory(cache, opts),
await stateServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
),
);

View File

@ -304,7 +304,7 @@ export class AddEditComponent extends BaseAddEditComponent {
}
private saveCipherState() {
return this.stateService.setAddEditCipherInfo({
return this.cipherService.setAddEditCipherInfo({
cipher: this.cipher,
collectionIds:
this.collections == null

View File

@ -544,13 +544,13 @@ export class Main {
this.encryptService,
this.cipherFileUploadService,
this.configService,
this.stateProvider,
);
this.folderService = new FolderService(
this.cryptoService,
this.i18nService,
this.cipherService,
this.stateService,
this.stateProvider,
);

View File

@ -10,6 +10,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { GeneratorComponent } from "./generator.component";
@ -54,6 +55,10 @@ describe("GeneratorComponent", () => {
provide: LogService,
useValue: mock<LogService>(),
},
{
provide: CipherService,
useValue: mock<CipherService>(),
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

View File

@ -800,8 +800,11 @@
"changeMasterPass": {
"message": "Change master password"
},
"changeMasterPasswordConfirmation": {
"message": "You can change your master password on the bitwarden.com web vault. Do you want to visit the website now?"
"continueToWebApp": {
"message": "Continue to web app?"
},
"changeMasterPasswordOnWebConfirmation": {
"message": "You can change your master password on the Bitwarden web app."
},
"fingerprintPhrase": {
"message": "Fingerprint phrase",

View File

@ -65,10 +65,10 @@ export class AccountMenu implements IMenubarMenu {
id: "changeMasterPass",
click: async () => {
const result = await dialog.showMessageBox(this._window, {
title: this.localize("changeMasterPass"),
message: this.localize("changeMasterPass"),
detail: this.localize("changeMasterPasswordConfirmation"),
buttons: [this.localize("yes"), this.localize("no")],
title: this.localize("continueToWebApp"),
message: this.localize("continueToWebApp"),
detail: this.localize("changeMasterPasswordOnWebConfirmation"),
buttons: [this.localize("continue"), this.localize("cancel")],
cancelId: 1,
defaultId: 0,
noLink: true,

View File

@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2024.4.0",
"version": "2024.4.1",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@ -31,7 +31,12 @@
</bit-tab>
<bit-tab label="{{ 'members' | i18n }}">
<p>{{ "editGroupMembersDesc" | i18n }}</p>
<p>
{{ "editGroupMembersDesc" | i18n }}
<span *ngIf="restrictGroupAccess$ | async">
{{ "restrictedGroupAccessDesc" | i18n }}
</span>
</p>
<bit-access-selector
formControlName="members"
[items]="members"

View File

@ -1,15 +1,31 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { catchError, combineLatest, from, map, of, Subject, switchMap, takeUntil } from "rxjs";
import {
catchError,
combineLatest,
concatMap,
from,
map,
Observable,
of,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
@ -88,10 +104,9 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
tabIndex: GroupAddEditTabType;
loading = true;
editMode = false;
title: string;
collections: AccessItemView[] = [];
members: AccessItemView[] = [];
members: Array<AccessItemView & { userId: UserId }> = [];
group: GroupView;
groupForm = this.formBuilder.group({
@ -110,6 +125,10 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
return this.params.organizationId;
}
protected get editMode(): boolean {
return this.groupId != null;
}
private destroy$ = new Subject<void>();
private get orgCollections$() {
@ -134,7 +153,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
);
}
private get orgMembers$() {
private get orgMembers$(): Observable<Array<AccessItemView & { userId: UserId }>> {
return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe(
map((response) =>
response.data.map((m) => ({
@ -145,34 +164,55 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
listName: m.name?.length > 0 ? `${m.name} (${m.email})` : m.email,
labelName: m.name || m.email,
status: m.status,
userId: m.userId as UserId,
})),
),
);
}
private get groupDetails$() {
if (!this.editMode) {
return of(undefined);
}
return combineLatest([
this.groupService.get(this.organizationId, this.groupId),
this.apiService.getGroupUsers(this.organizationId, this.groupId),
]).pipe(
map(([groupView, users]) => {
groupView.members = users;
return groupView;
}),
catchError((e: unknown) => {
if (e instanceof ErrorResponse) {
this.logService.error(e.message);
} else {
this.logService.error(e.toString());
}
private groupDetails$: Observable<GroupView | undefined> = of(this.editMode).pipe(
concatMap((editMode) => {
if (!editMode) {
return of(undefined);
}),
);
}
}
return combineLatest([
this.groupService.get(this.organizationId, this.groupId),
this.apiService.getGroupUsers(this.organizationId, this.groupId),
]).pipe(
map(([groupView, users]) => {
groupView.members = users;
return groupView;
}),
catchError((e: unknown) => {
if (e instanceof ErrorResponse) {
this.logService.error(e.message);
} else {
this.logService.error(e.toString());
}
return of(undefined);
}),
);
}),
shareReplay({ refCount: false }),
);
restrictGroupAccess$ = combineLatest([
this.organizationService.get$(this.organizationId),
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
this.groupDetails$,
]).pipe(
map(
([organization, flexibleCollectionsV1Enabled, group]) =>
// Feature flag conditionals
flexibleCollectionsV1Enabled &&
organization.flexibleCollections &&
// Business logic conditionals
!organization.allowAdminAccessToAllCollectionItems &&
group !== undefined,
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
@ -188,17 +228,25 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
private dialogService: DialogService,
private organizationService: OrganizationService,
private configService: ConfigService,
private accountService: AccountService,
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}
ngOnInit() {
this.editMode = this.loading = this.groupId != null;
this.loading = true;
this.title = this.i18nService.t(this.editMode ? "editGroup" : "newGroup");
combineLatest([this.orgCollections$, this.orgMembers$, this.groupDetails$])
combineLatest([
this.orgCollections$,
this.orgMembers$,
this.groupDetails$,
this.restrictGroupAccess$,
this.accountService.activeAccount$,
])
.pipe(takeUntil(this.destroy$))
.subscribe(([collections, members, group]) => {
.subscribe(([collections, members, group, restrictGroupAccess, activeAccount]) => {
this.collections = collections;
this.members = members;
this.group = group;
@ -224,6 +272,18 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
});
}
// If the current user is not already in the group and cannot add themselves, remove them from the list
if (restrictGroupAccess) {
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id).id;
const isAlreadyInGroup = this.groupForm.value.members.some(
(m) => m.id === organizationUserId,
);
if (!isAlreadyInGroup) {
this.members = this.members.filter((m) => m.id !== organizationUserId);
}
}
this.loading = false;
});
}

View File

@ -31,6 +31,7 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
import { DialogService } from "@bitwarden/components";
import { CollectionAdminService } from "../../../../../vault/core/collection-admin.service";
import { CollectionAdminView } from "../../../../../vault/core/views/collection-admin.view";
import {
CollectionAccessSelectionView,
GroupService,
@ -206,25 +207,52 @@ export class MemberDialogComponent implements OnDestroy {
collections: this.collectionAdminService.getAll(this.params.organizationId),
userDetails: userDetails$,
groups: groups$,
flexibleCollectionsV1Enabled: this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollectionsV1,
false,
),
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ organization, collections, userDetails, groups }) => {
this.setFormValidators(organization);
.subscribe(
({ organization, collections, userDetails, groups, flexibleCollectionsV1Enabled }) => {
this.setFormValidators(organization);
this.collectionAccessItems = [].concat(
collections.map((c) => mapCollectionToAccessItemView(c)),
);
// Groups tab: populate available groups
this.groupAccessItems = [].concat(
groups.map<AccessItemView>((g) => mapGroupToAccessItemView(g)),
);
this.groupAccessItems = [].concat(
groups.map<AccessItemView>((g) => mapGroupToAccessItemView(g)),
);
// Collections tab: Populate all available collections (including current user access where applicable)
this.collectionAccessItems = collections
.map((c) =>
mapCollectionToAccessItemView(
c,
organization,
flexibleCollectionsV1Enabled,
userDetails == null
? undefined
: c.users.find((access) => access.id === userDetails.id),
),
)
// But remove collections that we can't assign access to, unless the user is already assigned
.filter(
(item) =>
!item.readonly || userDetails?.collections.some((access) => access.id == item.id),
);
if (this.params.organizationUserId) {
this.loadOrganizationUser(userDetails, groups, collections);
}
if (userDetails != null) {
this.loadOrganizationUser(
userDetails,
groups,
collections,
organization,
flexibleCollectionsV1Enabled,
);
}
this.loading = false;
});
this.loading = false;
},
);
}
private setFormValidators(organization: Organization) {
@ -246,7 +274,9 @@ export class MemberDialogComponent implements OnDestroy {
private loadOrganizationUser(
userDetails: OrganizationUserAdminView,
groups: GroupView[],
collections: CollectionView[],
collections: CollectionAdminView[],
organization: Organization,
flexibleCollectionsV1Enabled: boolean,
) {
if (!userDetails) {
throw new Error("Could not find user to edit.");
@ -295,13 +325,22 @@ export class MemberDialogComponent implements OnDestroy {
}),
);
// Populate additional collection access via groups (rendered as separate rows from user access)
this.collectionAccessItems = this.collectionAccessItems.concat(
collectionsFromGroups.map(({ collection, accessSelection, group }) =>
mapCollectionToAccessItemView(collection, accessSelection, group),
mapCollectionToAccessItemView(
collection,
organization,
flexibleCollectionsV1Enabled,
accessSelection,
group,
),
),
);
const accessSelections = mapToAccessSelections(userDetails);
// Set current collections and groups the user has access to (excluding collections the current user doesn't have
// permissions to change - they are included as readonly via the CollectionAccessItems)
const accessSelections = mapToAccessSelections(userDetails, this.collectionAccessItems);
const groupAccessSelections = mapToGroupAccessSelections(userDetails.groups);
this.formGroup.removeControl("emails");
@ -573,6 +612,8 @@ export class MemberDialogComponent implements OnDestroy {
function mapCollectionToAccessItemView(
collection: CollectionView,
organization: Organization,
flexibleCollectionsV1Enabled: boolean,
accessSelection?: CollectionAccessSelectionView,
group?: GroupView,
): AccessItemView {
@ -581,7 +622,8 @@ function mapCollectionToAccessItemView(
id: group ? `${collection.id}-${group.id}` : collection.id,
labelName: collection.name,
listName: collection.name,
readonly: group !== undefined,
readonly:
group !== undefined || !collection.canEdit(organization, flexibleCollectionsV1Enabled),
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
viaGroupName: group?.name,
};
@ -596,16 +638,23 @@ function mapGroupToAccessItemView(group: GroupView): AccessItemView {
};
}
function mapToAccessSelections(user: OrganizationUserAdminView): AccessItemValue[] {
function mapToAccessSelections(
user: OrganizationUserAdminView,
items: AccessItemView[],
): AccessItemValue[] {
if (user == undefined) {
return [];
}
return [].concat(
user.collections.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Collection,
permission: convertToPermission(selection),
})),
return (
user.collections
// The FormControl value only represents editable collection access - exclude readonly access selections
.filter((selection) => !items.find((item) => item.id == selection.id).readonly)
.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Collection,
permission: convertToPermission(selection),
}))
);
}

View File

@ -1,13 +1,12 @@
<div class="d-flex tabbed-header">
<h1>
<div class="tw-flex tw-justify-between tw-mb-2 tw-pb-2 tw-mt-6">
<h2 bitTypography="h2">
{{ "billingHistory" | i18n }}
</h1>
</h2>
<button
type="button"
bitButton
buttonType="secondary"
(click)="load()"
class="tw-ml-auto"
*ngIf="firstLoaded"
[disabled]="loading"
>
@ -17,11 +16,11 @@
</div>
<ng-container *ngIf="!firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="billing">
<app-billing-history [billing]="billing"></app-billing-history>

View File

@ -170,8 +170,8 @@
</div>
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
<div class="mt-3">
<div class="d-flex" *ngIf="!showAdjustStorage">
<button bitButton type="button" buttonType="secondary" (click)="adjustStorage(true)">
<div class="d-flex">
<button bitButton type="button" buttonType="secondary" [bitAction]="adjustStorage(true)">
{{ "addStorage" | i18n }}
</button>
<button
@ -179,18 +179,11 @@
type="button"
buttonType="secondary"
class="tw-ml-1"
(click)="adjustStorage(false)"
[bitAction]="adjustStorage(false)"
>
{{ "removeStorage" | i18n }}
</button>
</div>
<app-adjust-storage
[storageGbPrice]="4"
[add]="adjustStorageAdd"
(onAdjusted)="closeStorage(true)"
(onCanceled)="closeStorage(false)"
*ngIf="showAdjustStorage"
></app-adjust-storage>
</div>
</ng-container>
</ng-container>

View File

@ -12,6 +12,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import {
AdjustStorageDialogResult,
openAdjustStorageDialog,
} from "../shared/adjust-storage.component";
import {
OffboardingSurveyDialogResultType,
openOffboardingSurvey,
@ -24,7 +28,6 @@ export class UserSubscriptionComponent implements OnInit {
loading = false;
firstLoaded = false;
adjustStorageAdd = true;
showAdjustStorage = false;
showUpdateLicense = false;
sub: SubscriptionResponse;
selfHosted = false;
@ -144,19 +147,20 @@ export class UserSubscriptionComponent implements OnInit {
}
}
adjustStorage(add: boolean) {
this.adjustStorageAdd = add;
this.showAdjustStorage = true;
}
closeStorage(load: boolean) {
this.showAdjustStorage = false;
if (load) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
}
}
adjustStorage = (add: boolean) => {
return async () => {
const dialogRef = openAdjustStorageDialog(this.dialogService, {
data: {
storageGbPrice: 4,
add: add,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogResult.Adjusted) {
await this.load();
}
};
};
get subscriptionMarkedForCancel() {
return (

View File

@ -1,10 +1,18 @@
<div class="card card-org-plans">
<div class="card-body">
<button type="button" class="close" appA11yTitle="{{ 'cancel' | i18n }}" (click)="cancel()">
<span aria-hidden="true">&times;</span>
</button>
<h2 class="card-body-header">{{ "changeBillingPlan" | i18n }}</h2>
<p class="mb-0">{{ "changeBillingPlanUpgrade" | i18n }}</p>
<div
class="tw-relative tw-flex tw-flex-col tw-min-w-0 tw-rounded tw-border tw-border-solid tw-border-secondary-300"
>
<div class="tw-flex-auto tw-p-5">
<button
bitIconButton="bwi-close"
buttonType="main"
type="button"
size="small"
class="tw-float-right"
appA11yTitle="{{ 'cancel' | i18n }}"
(click)="cancel()"
></button>
<h2 bitTypography="h2">{{ "changeBillingPlan" | i18n }}</h2>
<p bitTypography="body1" class="tw-mb-0">{{ "changeBillingPlanUpgrade" | i18n }}</p>
<app-organization-plans
[showFree]="false"
[showCancel]="true"

View File

@ -16,11 +16,11 @@
<bit-container>
<ng-container *ngIf="!firstLoaded && loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="billing">
<app-billing-history [billing]="billing"></app-billing-history>

View File

@ -175,23 +175,24 @@
<bit-progress [barWidth]="storagePercentage" bgColor="success"></bit-progress>
<ng-container *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel">
<div class="tw-mt-3">
<div class="tw-flex tw-space-x-2" *ngIf="!showAdjustStorage">
<button bitButton buttonType="secondary" type="button" (click)="adjustStorage(true)">
<div class="tw-flex tw-space-x-2">
<button
bitButton
buttonType="secondary"
type="button"
[bitAction]="adjustStorage(true)"
>
{{ "addStorage" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" (click)="adjustStorage(false)">
<button
bitButton
buttonType="secondary"
type="button"
[bitAction]="adjustStorage(false)"
>
{{ "removeStorage" | i18n }}
</button>
</div>
<app-adjust-storage
[storageGbPrice]="storageGbPrice"
[add]="adjustStorageAdd"
[organizationId]="organizationId"
[interval]="billingInterval"
(onAdjusted)="closeStorage(true)"
(onCanceled)="closeStorage(false)"
*ngIf="showAdjustStorage"
></app-adjust-storage>
</div>
</ng-container>
<ng-container *ngIf="showAdjustSecretsManager">

View File

@ -18,6 +18,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import {
AdjustStorageDialogResult,
openAdjustStorageDialog,
} from "../shared/adjust-storage.component";
import {
OffboardingSurveyDialogResultType,
openOffboardingSurvey,
@ -36,8 +40,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
userOrg: Organization;
showChangePlan = false;
showDownloadLicense = false;
adjustStorageAdd = true;
showAdjustStorage = false;
hasBillingSyncToken: boolean;
showAdjustSecretsManager = false;
showSecretsManagerSubscribe = false;
@ -361,19 +363,22 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
this.load();
}
adjustStorage(add: boolean) {
this.adjustStorageAdd = add;
this.showAdjustStorage = true;
}
closeStorage(load: boolean) {
this.showAdjustStorage = false;
if (load) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
}
}
adjustStorage = (add: boolean) => {
return async () => {
const dialogRef = openAdjustStorageDialog(this.dialogService, {
data: {
storageGbPrice: this.storageGbPrice,
add: add,
organizationId: this.organizationId,
interval: this.billingInterval,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustStorageDialogResult.Adjusted) {
await this.load();
}
};
};
removeSponsorship = async () => {
const confirmed = await this.dialogService.openSimpleDialog({

View File

@ -0,0 +1,25 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog
dialogSize="large"
[title]="(currentType != null ? 'changePaymentMethod' : 'addPaymentMethod') | i18n"
>
<ng-container bitDialogContent>
<app-payment [hideBank]="!organizationId" [hideCredit]="true"></app-payment>
<app-tax-info (onCountryChanged)="changeCountry()"></app-tax-info>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,110 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, ViewChild } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { PaymentComponent } from "./payment.component";
import { TaxInfoComponent } from "./tax-info.component";
export interface AdjustPaymentDialogData {
organizationId: string;
currentType: PaymentMethodType;
}
export enum AdjustPaymentDialogResult {
Adjusted = "adjusted",
Cancelled = "cancelled",
}
@Component({
templateUrl: "adjust-payment-dialog.component.html",
})
export class AdjustPaymentDialogComponent {
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent, { static: true }) taxInfoComponent: TaxInfoComponent;
organizationId: string;
currentType: PaymentMethodType;
paymentMethodType = PaymentMethodType;
protected DialogResult = AdjustPaymentDialogResult;
protected formGroup = new FormGroup({});
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AdjustPaymentDialogData,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private paymentMethodWarningService: PaymentMethodWarningService,
) {
this.organizationId = data.organizationId;
this.currentType = data.currentType;
}
submit = async () => {
const request = new PaymentRequest();
const response = this.paymentComponent.createPaymentToken().then((result) => {
request.paymentToken = result[0];
request.paymentMethodType = result[1];
request.postalCode = this.taxInfoComponent.taxInfo.postalCode;
request.country = this.taxInfoComponent.taxInfo.country;
if (this.organizationId == null) {
return this.apiService.postAccountPayment(request);
} else {
request.taxId = this.taxInfoComponent.taxInfo.taxId;
request.state = this.taxInfoComponent.taxInfo.state;
request.line1 = this.taxInfoComponent.taxInfo.line1;
request.line2 = this.taxInfoComponent.taxInfo.line2;
request.city = this.taxInfoComponent.taxInfo.city;
request.state = this.taxInfoComponent.taxInfo.state;
return this.organizationApiService.updatePayment(this.organizationId, request);
}
});
await response;
if (this.organizationId) {
await this.paymentMethodWarningService.removeSubscriptionRisk(this.organizationId);
}
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("updatedPaymentMethod"),
);
this.dialogRef.close(AdjustPaymentDialogResult.Adjusted);
};
changeCountry() {
if (this.taxInfoComponent.taxInfo.country === "US") {
this.paymentComponent.hideBank = !this.organizationId;
} else {
this.paymentComponent.hideBank = true;
if (this.paymentComponent.method === PaymentMethodType.BankAccount) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
}
}
/**
* Strongly typed helper to open a AdjustPaymentDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAdjustPaymentDialog(
dialogService: DialogService,
config: DialogConfig<AdjustPaymentDialogData>,
) {
return dialogService.open<AdjustPaymentDialogResult>(AdjustPaymentDialogComponent, config);
}

View File

@ -1,19 +0,0 @@
<form #form class="card" (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="card-body">
<button type="button" class="close" appA11yTitle="{{ 'cancel' | i18n }}" (click)="cancel()">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="card-body-header">
{{ (currentType != null ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</h3>
<app-payment [hideBank]="!organizationId" [hideCredit]="true"></app-payment>
<app-tax-info (onCountryChanged)="changeCountry()"></app-tax-info>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "submit" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</div>
</form>

View File

@ -1,90 +0,0 @@
import { Component, EventEmitter, Input, Output, ViewChild } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PaymentComponent } from "./payment.component";
import { TaxInfoComponent } from "./tax-info.component";
@Component({
selector: "app-adjust-payment",
templateUrl: "adjust-payment.component.html",
})
export class AdjustPaymentComponent {
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent, { static: true }) taxInfoComponent: TaxInfoComponent;
@Input() currentType?: PaymentMethodType;
@Input() organizationId: string;
@Output() onAdjusted = new EventEmitter();
@Output() onCanceled = new EventEmitter();
paymentMethodType = PaymentMethodType;
formPromise: Promise<void>;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private paymentMethodWarningService: PaymentMethodWarningService,
) {}
async submit() {
try {
const request = new PaymentRequest();
this.formPromise = this.paymentComponent.createPaymentToken().then((result) => {
request.paymentToken = result[0];
request.paymentMethodType = result[1];
request.postalCode = this.taxInfoComponent.taxInfo.postalCode;
request.country = this.taxInfoComponent.taxInfo.country;
if (this.organizationId == null) {
return this.apiService.postAccountPayment(request);
} else {
request.taxId = this.taxInfoComponent.taxInfo.taxId;
request.state = this.taxInfoComponent.taxInfo.state;
request.line1 = this.taxInfoComponent.taxInfo.line1;
request.line2 = this.taxInfoComponent.taxInfo.line2;
request.city = this.taxInfoComponent.taxInfo.city;
request.state = this.taxInfoComponent.taxInfo.state;
return this.organizationApiService.updatePayment(this.organizationId, request);
}
});
await this.formPromise;
if (this.organizationId) {
await this.paymentMethodWarningService.removeSubscriptionRisk(this.organizationId);
}
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("updatedPaymentMethod"),
);
this.onAdjusted.emit();
} catch (e) {
this.logService.error(e);
}
}
cancel() {
this.onCanceled.emit();
}
changeCountry() {
if (this.taxInfoComponent.taxInfo.country === "US") {
this.paymentComponent.hideBank = !this.organizationId;
} else {
this.paymentComponent.hideBank = true;
if (this.paymentComponent.method === PaymentMethodType.BankAccount) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
}
}

View File

@ -1,43 +1,35 @@
<form #form class="card" (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="card-body">
<button type="button" class="close" appA11yTitle="{{ 'cancel' | i18n }}" (click)="cancel()">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="card-body-header">{{ (add ? "addStorage" : "removeStorage") | i18n }}</h3>
<div class="row">
<div class="form-group col-6">
<label for="storageAdjustment">{{
(add ? "gbStorageAdd" : "gbStorageRemove") | i18n
}}</label>
<input
id="storageAdjustment"
class="form-control"
type="number"
name="StorageGbAdjustment"
[(ngModel)]="storageAdjustment"
min="0"
max="99"
step="1"
required
/>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="(add ? 'addStorage' : 'removeStorage') | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ (add ? "storageAddNote" : "storageRemoveNote") | i18n }}</p>
<div class="tw-grid tw-grid-cols-12">
<bit-form-field class="tw-col-span-7">
<bit-label>{{ (add ? "gbStorageAdd" : "gbStorageRemove") | i18n }}</bit-label>
<input bitInput type="number" formControlName="storageAdjustment" />
<bit-hint *ngIf="add">
<strong>{{ "total" | i18n }}:</strong>
{{ formGroup.get("storageAdjustment").value || 0 }} GB &times;
{{ storageGbPrice | currency: "$" }} = {{ adjustedStorageTotal | currency: "$" }} /{{
interval | i18n
}}
</bit-hint>
</bit-form-field>
</div>
</div>
<div *ngIf="add" class="mb-3">
<strong>{{ "total" | i18n }}:</strong> {{ storageAdjustment || 0 }} GB &times;
{{ storageGbPrice | currency: "$" }} = {{ adjustedStorageTotal | currency: "$" }} /{{
interval | i18n
}}
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "submit" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
<small class="d-block text-muted mt-3">
{{ (add ? "storageAddNote" : "storageRemoveNote") | i18n }}
</small>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<app-payment [showMethods]="false"></app-payment>

View File

@ -1,4 +1,6 @@
import { Component, EventEmitter, Input, Output, ViewChild } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -8,27 +10,45 @@ import { StorageRequest } from "@bitwarden/common/models/request/storage.request
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { PaymentComponent } from "./payment.component";
export interface AdjustStorageDialogData {
storageGbPrice: number;
add: boolean;
organizationId?: string;
interval?: string;
}
export enum AdjustStorageDialogResult {
Adjusted = "adjusted",
Cancelled = "cancelled",
}
@Component({
selector: "app-adjust-storage",
templateUrl: "adjust-storage.component.html",
})
export class AdjustStorageComponent {
@Input() storageGbPrice = 0;
@Input() add = true;
@Input() organizationId: string;
@Input() interval = "year";
@Output() onAdjusted = new EventEmitter<number>();
@Output() onCanceled = new EventEmitter();
storageGbPrice: number;
add: boolean;
organizationId: string;
interval: string;
@ViewChild(PaymentComponent, { static: true }) paymentComponent: PaymentComponent;
storageAdjustment = 0;
formPromise: Promise<PaymentResponse | void>;
protected DialogResult = AdjustStorageDialogResult;
protected formGroup = new FormGroup({
storageAdjustment: new FormControl(0, [
Validators.required,
Validators.min(0),
Validators.max(99),
]),
});
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AdjustStorageDialogData,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
@ -36,69 +56,74 @@ export class AdjustStorageComponent {
private activatedRoute: ActivatedRoute,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
) {}
) {
this.storageGbPrice = data.storageGbPrice;
this.add = data.add;
this.organizationId = data.organizationId;
this.interval = data.interval || "year";
}
async submit() {
try {
const request = new StorageRequest();
request.storageGbAdjustment = this.storageAdjustment;
if (!this.add) {
request.storageGbAdjustment *= -1;
}
let paymentFailed = false;
const action = async () => {
let response: Promise<PaymentResponse>;
if (this.organizationId == null) {
response = this.formPromise = this.apiService.postAccountStorage(request);
} else {
response = this.formPromise = this.organizationApiService.updateStorage(
this.organizationId,
request,
);
}
const result = await response;
if (result != null && result.paymentIntentClientSecret != null) {
try {
await this.paymentComponent.handleStripeCardPayment(
result.paymentIntentClientSecret,
null,
);
} catch {
paymentFailed = true;
}
}
};
this.formPromise = action();
await this.formPromise;
this.onAdjusted.emit(this.storageAdjustment);
if (paymentFailed) {
this.platformUtilsService.showToast(
"warning",
null,
this.i18nService.t("couldNotChargeCardPayInvoice"),
{ timeout: 10000 },
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["../billing"], { relativeTo: this.activatedRoute });
} else {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("adjustedStorage", request.storageGbAdjustment.toString()),
);
}
} catch (e) {
this.logService.error(e);
submit = async () => {
const request = new StorageRequest();
request.storageGbAdjustment = this.formGroup.value.storageAdjustment;
if (!this.add) {
request.storageGbAdjustment *= -1;
}
}
cancel() {
this.onCanceled.emit();
}
let paymentFailed = false;
const action = async () => {
let response: Promise<PaymentResponse>;
if (this.organizationId == null) {
response = this.apiService.postAccountStorage(request);
} else {
response = this.organizationApiService.updateStorage(this.organizationId, request);
}
const result = await response;
if (result != null && result.paymentIntentClientSecret != null) {
try {
await this.paymentComponent.handleStripeCardPayment(
result.paymentIntentClientSecret,
null,
);
} catch {
paymentFailed = true;
}
}
};
await action();
this.dialogRef.close(AdjustStorageDialogResult.Adjusted);
if (paymentFailed) {
this.platformUtilsService.showToast(
"warning",
null,
this.i18nService.t("couldNotChargeCardPayInvoice"),
{ timeout: 10000 },
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["../billing"], { relativeTo: this.activatedRoute });
} else {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("adjustedStorage", request.storageGbAdjustment.toString()),
);
}
};
get adjustedStorageTotal(): number {
return this.storageGbPrice * this.storageAdjustment;
return this.storageGbPrice * this.formGroup.value.storageAdjustment;
}
}
/**
* Strongly typed helper to open an AdjustStorageDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAdjustStorageDialog(
dialogService: DialogService,
config: DialogConfig<AdjustStorageDialogData>,
) {
return dialogService.open<AdjustStorageDialogResult>(AdjustStorageComponent, config);
}

View File

@ -1,65 +1,72 @@
<h2 class="mt-3">{{ "invoices" | i18n }}</h2>
<p *ngIf="!invoices || !invoices.length">{{ "noInvoices" | i18n }}</p>
<table class="table mb-2" *ngIf="invoices && invoices.length">
<tbody>
<tr *ngFor="let i of invoices">
<td>{{ i.date | date: "mediumDate" }}</td>
<td>
<a
href="{{ i.pdfUrl }}"
target="_blank"
rel="noreferrer"
class="mr-2"
appA11yTitle="{{ 'downloadInvoice' | i18n }}"
<bit-section>
<h3 bitTypography="h3">{{ "invoices" | i18n }}</h3>
<p bitTypography="body1" *ngIf="!invoices || !invoices.length">{{ "noInvoices" | i18n }}</p>
<bit-table>
<ng-template body>
<tr bitRow *ngFor="let i of invoices">
<td bitCell>{{ i.date | date: "mediumDate" }}</td>
<td bitCell>
<a
href="{{ i.pdfUrl }}"
target="_blank"
rel="noreferrer"
class="tw-mr-2"
appA11yTitle="{{ 'downloadInvoice' | i18n }}"
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
></a>
<a href="{{ i.url }}" target="_blank" rel="noreferrer" title="{{ 'viewInvoice' | i18n }}">
{{ "invoiceNumber" | i18n: i.number }}</a
>
</td>
<td bitCell>{{ i.amount | currency: "$" }}</td>
<td bitCell>
<span *ngIf="i.paid">
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
{{ "paid" | i18n }}
</span>
<span *ngIf="!i.paid">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n }}
</span>
</td>
</tr>
</ng-template>
</bit-table>
</bit-section>
<bit-section>
<h3 bitTypography="h3">{{ "transactions" | i18n }}</h3>
<p bitTypography="body1" *ngIf="!transactions || !transactions.length">
{{ "noTransactions" | i18n }}
</p>
<bit-table *ngIf="transactions && transactions.length">
<ng-template body>
<tr bitRow *ngFor="let t of transactions">
<td bitCell>{{ t.createdDate | date: "mediumDate" }}</td>
<td bitCell>
<span *ngIf="t.type === transactionType.Charge || t.type === transactionType.Credit">
{{ "chargeNoun" | i18n }}
</span>
<span *ngIf="t.type === transactionType.Refund">{{ "refundNoun" | i18n }}</span>
</td>
<td bitCell>
<i
class="bwi bwi-fw"
*ngIf="t.paymentMethodType"
aria-hidden="true"
[ngClass]="paymentMethodClasses(t.paymentMethodType)"
></i>
{{ t.details }}
</td>
<td
[ngClass]="{ 'text-strike': t.refunded }"
title="{{ (t.refunded ? 'refunded' : '') | i18n }}"
bitCell
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
></a>
<a href="{{ i.url }}" target="_blank" rel="noreferrer" title="{{ 'viewInvoice' | i18n }}">
{{ "invoiceNumber" | i18n: i.number }}</a
>
</td>
<td>{{ i.amount | currency: "$" }}</td>
<td>
<span *ngIf="i.paid">
<i class="bwi bwi-check text-success" aria-hidden="true"></i>
{{ "paid" | i18n }}
</span>
<span *ngIf="!i.paid">
<i class="bwi bwi-exclamation-circle text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n }}
</span>
</td>
</tr>
</tbody>
</table>
<h2 class="spaced-header">{{ "transactions" | i18n }}</h2>
<p *ngIf="!transactions || !transactions.length">{{ "noTransactions" | i18n }}</p>
<table class="table mb-2" *ngIf="transactions && transactions.length">
<tbody>
<tr *ngFor="let t of transactions">
<td>{{ t.createdDate | date: "mediumDate" }}</td>
<td>
<span *ngIf="t.type === transactionType.Charge || t.type === transactionType.Credit">
{{ "chargeNoun" | i18n }}
</span>
<span *ngIf="t.type === transactionType.Refund">{{ "refundNoun" | i18n }}</span>
</td>
<td>
<i
class="bwi bwi-fw"
*ngIf="t.paymentMethodType"
aria-hidden="true"
[ngClass]="paymentMethodClasses(t.paymentMethodType)"
></i>
{{ t.details }}
</td>
<td
[ngClass]="{ 'text-strike': t.refunded }"
title="{{ (t.refunded ? 'refunded' : '') | i18n }}"
>
{{ t.amount | currency: "$" }}
</td>
</tr>
</tbody>
</table>
<small class="text-muted">* {{ "chargesStatement" | i18n: "BITWARDEN" }}</small>
{{ t.amount | currency: "$" }}
</td>
</tr>
</ng-template>
</bit-table>
<small class="tw-text-muted">* {{ "chargesStatement" | i18n: "BITWARDEN" }}</small>
</bit-section>

View File

@ -4,7 +4,7 @@ import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
import { AddCreditComponent } from "./add-credit.component";
import { AdjustPaymentComponent } from "./adjust-payment.component";
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog.component";
import { AdjustStorageComponent } from "./adjust-storage.component";
import { BillingHistoryComponent } from "./billing-history.component";
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
@ -18,7 +18,7 @@ import { UpdateLicenseComponent } from "./update-license.component";
imports: [SharedModule, PaymentComponent, TaxInfoComponent, HeaderModule],
declarations: [
AddCreditComponent,
AdjustPaymentComponent,
AdjustPaymentDialogComponent,
AdjustStorageComponent,
BillingHistoryComponent,
PaymentMethodComponent,

View File

@ -15,7 +15,7 @@
<bit-container>
<div class="tabbed-header" *ngIf="!organizationId">
<!-- TODO: Organization and individual should use different "page" components -->
<!--TODO: Organization and individual should use different "page" components -->
<h1>{{ "paymentMethod" | i18n }}</h1>
</div>
@ -102,23 +102,9 @@
{{ paymentSource.description }}
</p>
</ng-container>
<button
type="button"
bitButton
buttonType="secondary"
(click)="changePayment()"
*ngIf="!showAdjustPayment"
>
<button type="button" bitButton buttonType="secondary" [bitAction]="changePayment">
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
</button>
<app-adjust-payment
[organizationId]="organizationId"
[currentType]="paymentSource != null ? paymentSource.type : null"
(onAdjusted)="closePayment(true)"
(onCanceled)="closePayment(false)"
*ngIf="showAdjustPayment"
>
</app-adjust-payment>
<p *ngIf="isUnpaid">{{ "paymentChargedWithUnpaidSubscription" | i18n }}</p>
<ng-container *ngIf="forOrganization">
<h2 class="spaced-header">{{ "taxInformation" | i18n }}</h2>

View File

@ -1,6 +1,7 @@
import { Component, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@ -14,6 +15,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import {
AdjustPaymentDialogResult,
openAdjustPaymentDialog,
} from "./adjust-payment-dialog.component";
import { TaxInfoComponent } from "./tax-info.component";
@Component({
@ -25,7 +30,6 @@ export class PaymentMethodComponent implements OnInit {
loading = false;
firstLoaded = false;
showAdjustPayment = false;
showAddCredit = false;
billing: BillingPaymentResponse;
org: OrganizationSubscriptionResponse;
@ -120,18 +124,18 @@ export class PaymentMethodComponent implements OnInit {
}
}
changePayment() {
this.showAdjustPayment = true;
}
closePayment(load: boolean) {
this.showAdjustPayment = false;
if (load) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
changePayment = async () => {
const dialogRef = openAdjustPaymentDialog(this.dialogService, {
data: {
organizationId: this.organizationId,
currentType: this.paymentSource !== null ? this.paymentSource.type : null,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResult.Adjusted) {
await this.load();
}
}
};
async verifyBank() {
if (this.loading || !this.forOrganization) {

View File

@ -18,7 +18,6 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Account } from "./account";
import { GlobalState } from "./global-state";
@ -57,19 +56,6 @@ export class StateService extends BaseStateService<GlobalState, Account> {
await super.addAccount(account);
}
async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.getEncryptedCiphers(options);
}
async setEncryptedCiphers(
value: { [id: string]: CipherData },
options?: StorageOptions,
): Promise<void> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.setEncryptedCiphers(value, options);
}
override async getLastSync(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.getLastSync(options);

View File

@ -124,6 +124,9 @@ export class CollectionAdminService {
view.groups = c.groups;
view.users = c.users;
view.assigned = c.assigned;
view.readOnly = c.readOnly;
view.hidePasswords = c.hidePasswords;
view.manage = c.manage;
}
return view;

View File

@ -1,64 +1,52 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="collectionsTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="collectionsTitle">
{{ "collections" | i18n }}
<small *ngIf="cipher">{{ cipher.name }}</small>
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ "collectionsDesc" | i18n }}</p>
<div class="d-flex">
<h3>{{ "collections" | i18n }}</h3>
<div class="ml-auto d-flex" *ngIf="collections && collections.length">
<button type="button" (click)="selectAll(true)" class="btn btn-link btn-sm py-0">
{{ "selectAll" | i18n }}
</button>
<button type="button" (click)="selectAll(false)" class="btn btn-link btn-sm py-0">
{{ "unselectAll" | i18n }}
</button>
</div>
<form (ngSubmit)="submit()">
<bit-dialog>
<span bitDialogTitle>
{{ "collections" | i18n }}
<small *ngIf="cipher">{{ cipher.name }}</small>
</span>
<ng-container bitDialogContent>
<p>{{ "collectionsDesc" | i18n }}</p>
<div class="tw-flex">
<label class="tw-mb-1 tw-block tw-font-semibold tw-text-main">{{
"collections" | i18n
}}</label>
<div class="tw-ml-auto tw-flex" *ngIf="collections && collections.length">
<button bitLink type="button" (click)="selectAll(true)" class="tw-px-2">
{{ "selectAll" | i18n }}
</button>
<button bitLink type="button" (click)="selectAll(false)" class="tw-px-2">
{{ "unselectAll" | i18n }}
</button>
</div>
<div *ngIf="!collections || !collections.length">
{{ "noCollectionsInList" | i18n }}
</div>
<table class="table table-hover table-list mb-0" *ngIf="collections && collections.length">
<tbody>
<tr *ngFor="let c of collections; let i = index" (click)="check(c)">
<td class="table-list-checkbox">
<input
type="checkbox"
[(ngModel)]="$any(c).checked"
name="Collection[{{ i }}].Checked"
appStopProp
[disabled]="!c.canEditItems(this.organization, this.flexibleCollectionsV1Enabled)"
/>
</td>
<td>
{{ c.name }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
<div *ngIf="!collections || !collections.length">
{{ "noCollectionsInList" | i18n }}
</div>
</form>
</div>
</div>
<bit-table *ngIf="collections && collections.length">
<ng-template body>
<tr bitRow *ngFor="let c of collections; let i = index" (click)="check(c)">
<td bitCell>
<input
type="checkbox"
bitCheckbox
[(ngModel)]="$any(c).checked"
name="Collection[{{ i }}].Checked"
appStopProp
[disabled]="!c.canEditItems(this.organization, this.flexibleCollectionsV1Enabled)"
/>
{{ c.name }}
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="submit">
{{ "save" | i18n }}
</button>
<button bitButton bitDialogClose buttonType="secondary" type="button">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -1,4 +1,5 @@
import { Component, OnDestroy } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, OnDestroy, Inject } from "@angular/core";
import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -8,6 +9,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { DialogService } from "@bitwarden/components";
@Component({
selector: "app-vault-collections",
@ -21,6 +23,8 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On
cipherService: CipherService,
organizationSerivce: OrganizationService,
logService: LogService,
protected dialogRef: DialogRef,
@Inject(DIALOG_DATA) params: CollectionsDialogParams,
) {
super(
collectionService,
@ -30,10 +34,16 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On
organizationSerivce,
logService,
);
this.cipherId = params?.cipherId;
}
ngOnDestroy() {
this.selectAll(false);
override async submit(): Promise<boolean> {
const success = await super.submit();
if (success) {
this.dialogRef.close(CollectionsDialogResult.Saved);
return true;
}
return false;
}
check(c: CollectionView, select?: boolean) {
@ -46,4 +56,31 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On
selectAll(select: boolean) {
this.collections.forEach((c) => this.check(c, select));
}
ngOnDestroy() {
this.selectAll(false);
}
}
export interface CollectionsDialogParams {
cipherId: string;
}
export enum CollectionsDialogResult {
Saved = "saved",
}
/**
* Strongly typed helper to open a Collections dialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Optional configuration for the dialog
*/
export function openIndividualVaultCollectionsDialog(
dialogService: DialogService,
config?: DialogConfig<CollectionsDialogParams>,
) {
return dialogService.open<CollectionsDialogResult, CollectionsDialogParams>(
CollectionsComponent,
config,
);
}

View File

@ -86,7 +86,7 @@ import {
BulkShareDialogResult,
openBulkShareDialog,
} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component";
import { CollectionsComponent } from "./collections.component";
import { openIndividualVaultCollectionsDialog } from "./collections.component";
import { FolderAddEditDialogResult, openFolderAddEditDialog } from "./folder-add-edit.component";
import { ShareComponent } from "./share.component";
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
@ -568,17 +568,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async editCipherCollections(cipher: CipherView) {
const [modal] = await this.modalService.openViewRef(
CollectionsComponent,
this.collectionsModalRef,
(comp) => {
comp.cipherId = cipher.id;
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
this.refresh();
});
},
);
openIndividualVaultCollectionsDialog(this.dialogService, { data: { cipherId: cipher.id } });
}
async addCipher() {

View File

@ -1,4 +1,5 @@
import { Component } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -11,8 +12,13 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherCollectionsRequest } from "@bitwarden/common/vault/models/request/cipher-collections.request";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { DialogService } from "@bitwarden/components";
import { CollectionsComponent as BaseCollectionsComponent } from "../individual-vault/collections.component";
import {
CollectionsComponent as BaseCollectionsComponent,
CollectionsDialogResult,
} from "../individual-vault/collections.component";
@Component({
selector: "app-org-vault-collections",
@ -29,6 +35,8 @@ export class CollectionsComponent extends BaseCollectionsComponent {
organizationService: OrganizationService,
private apiService: ApiService,
logService: LogService,
protected dialogRef: DialogRef,
@Inject(DIALOG_DATA) params: OrgVaultCollectionsDialogParams,
) {
super(
collectionService,
@ -37,8 +45,14 @@ export class CollectionsComponent extends BaseCollectionsComponent {
cipherService,
organizationService,
logService,
dialogRef,
params,
);
this.allowSelectNone = true;
this.collectionIds = params?.collectionIds;
this.collections = params?.collections;
this.organization = params?.organization;
this.cipherId = params?.cipherId;
}
protected async loadCipher() {
@ -79,3 +93,25 @@ export class CollectionsComponent extends BaseCollectionsComponent {
}
}
}
export interface OrgVaultCollectionsDialogParams {
collectionIds: string[];
collections: CollectionView[];
organization: Organization;
cipherId: string;
}
/**
* Strongly typed helper to open a Collections dialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Optional configuration for the dialog
*/
export function openOrgVaultCollectionsDialog(
dialogService: DialogService,
config?: DialogConfig<OrgVaultCollectionsDialogParams>,
) {
return dialogService.open<CollectionsDialogResult, OrgVaultCollectionsDialogParams>(
CollectionsComponent,
config,
);
}

View File

@ -75,6 +75,7 @@ import {
BulkDeleteDialogResult,
openBulkDeleteDialog,
} from "../individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component";
import { CollectionsDialogResult } from "../individual-vault/collections.component";
import { RoutedVaultFilterBridgeService } from "../individual-vault/vault-filter/services/routed-vault-filter-bridge.service";
import { RoutedVaultFilterService } from "../individual-vault/vault-filter/services/routed-vault-filter.service";
import { createFilterFunction } from "../individual-vault/vault-filter/shared/models/filter-function";
@ -95,7 +96,7 @@ import {
BulkCollectionsDialogComponent,
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionsComponent } from "./collections.component";
import { openOrgVaultCollectionsDialog } from "./collections.component";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
const BroadcasterSubscriptionId = "OrgVaultComponent";
@ -711,21 +712,37 @@ export class VaultComponent implements OnInit, OnDestroy {
} else {
collections = await firstValueFrom(this.allCollectionsWithoutUnassigned$);
}
const [modal] = await this.modalService.openViewRef(
CollectionsComponent,
this.collectionsModalRef,
(comp) => {
comp.flexibleCollectionsV1Enabled = this.flexibleCollectionsV1Enabled;
comp.collectionIds = cipher.collectionIds;
comp.collections = collections;
comp.organization = this.organization;
comp.cipherId = cipher.id;
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
this.refresh();
});
const dialog = openOrgVaultCollectionsDialog(this.dialogService, {
data: {
collectionIds: cipher.collectionIds,
collections: collections.filter((c) => !c.readOnly && c.id != Unassigned),
organization: this.organization,
cipherId: cipher.id,
},
);
});
/**
const [modal] = await this.modalService.openViewRef(
CollectionsComponent,
this.collectionsModalRef,
(comp) => {
comp.flexibleCollectionsV1Enabled = this.flexibleCollectionsV1Enabled;
comp.collectionIds = cipher.collectionIds;
comp.collections = collections;
comp.organization = this.organization;
comp.cipherId = cipher.id;
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
this.refresh();
});
},
);
*/
if ((await lastValueFrom(dialog.closed)) == CollectionsDialogResult.Saved) {
await this.refresh();
}
}
async addCipher() {

View File

@ -7905,5 +7905,8 @@
},
"unassignedItemsBannerSelfHost": {
"message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in your All Vaults view across devices and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."
},
"restrictedGroupAccessDesc": {
"message": "You cannot add yourself to a group."
}
}

View File

@ -59,7 +59,7 @@ export class CollectionsComponent implements OnInit {
}
}
async submit() {
async submit(): Promise<boolean> {
const selectedCollectionIds = this.collections
.filter((c) => {
if (this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
@ -75,7 +75,7 @@ export class CollectionsComponent implements OnInit {
this.i18nService.t("errorOccurred"),
this.i18nService.t("selectOneCollection"),
);
return;
return false;
}
this.cipherDomain.collectionIds = selectedCollectionIds;
try {
@ -83,8 +83,10 @@ export class CollectionsComponent implements OnInit {
await this.formPromise;
this.onSavedCollections.emit();
this.platformUtilsService.showToast("success", null, this.i18nService.t("editedItem"));
return true;
} catch (e) {
this.logService.error(e);
return false;
}
}

View File

@ -411,6 +411,7 @@ const safeProviders: SafeProvider[] = [
encryptService: EncryptService,
fileUploadService: CipherFileUploadServiceAbstraction,
configService: ConfigService,
stateProvider: StateProvider,
) =>
new CipherService(
cryptoService,
@ -423,6 +424,7 @@ const safeProviders: SafeProvider[] = [
encryptService,
fileUploadService,
configService,
stateProvider,
),
deps: [
CryptoServiceAbstraction,
@ -435,6 +437,7 @@ const safeProviders: SafeProvider[] = [
EncryptService,
CipherFileUploadServiceAbstraction,
ConfigService,
StateProvider,
],
}),
safeProvider({
@ -444,7 +447,6 @@ const safeProviders: SafeProvider[] = [
CryptoServiceAbstraction,
I18nServiceAbstraction,
CipherServiceAbstraction,
StateServiceAbstraction,
StateProvider,
],
}),

View File

@ -1,6 +1,6 @@
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { concatMap, Observable, Subject, takeUntil } from "rxjs";
import { concatMap, firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
@ -687,7 +687,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
async loadAddEditCipherInfo(): Promise<boolean> {
const addEditCipherInfo: any = await this.stateService.getAddEditCipherInfo();
const addEditCipherInfo: any = await firstValueFrom(this.cipherService.addEditCipherInfo$);
const loadedSavedInfo = addEditCipherInfo != null;
if (loadedSavedInfo) {
@ -700,7 +700,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
}
await this.stateService.setAddEditCipherInfo(null);
await this.cipherService.setAddEditCipherInfo(null);
return loadedSavedInfo;
}

View File

@ -166,8 +166,8 @@ export abstract class LoginStrategy {
const userId = accountInformation.sub;
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
const vaultTimeout = await this.stateService.getVaultTimeout();
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
// set access token and refresh token before account initialization so authN status can be accurate
// User id will be derived from the access token.

View File

@ -297,7 +297,6 @@ export abstract class ApiService {
) => Promise<any>;
getGroupUsers: (organizationId: string, id: string) => Promise<string[]>;
putGroupUsers: (organizationId: string, id: string, request: string[]) => Promise<any>;
deleteGroupUser: (organizationId: string, id: string, organizationUserId: string) => Promise<any>;
getSync: () => Promise<SyncResponse>;

View File

@ -23,7 +23,6 @@ import {
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
describe("TokenService", () => {
@ -1120,20 +1119,13 @@ describe("TokenService", () => {
secureStorageOptions,
);
// assert data was migrated out of disk and memory + flag was set
// assert data was migrated out of disk and memory
expect(
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
).toHaveBeenCalledWith(null);
expect(
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock,
).toHaveBeenCalledWith(null);
expect(
singleUserStateProvider.getFake(
userIdFromAccessToken,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
).nextMock,
).toHaveBeenCalledWith(true);
});
});
});
@ -1260,11 +1252,6 @@ describe("TokenService", () => {
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
// set access token migration flag to true
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, true]);
// Act
const result = await tokenService.getRefreshToken();
// Assert
@ -1284,11 +1271,6 @@ describe("TokenService", () => {
secureStorageService.get.mockResolvedValue(refreshToken);
// set access token migration flag to true
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, true]);
// Act
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
// Assert
@ -1305,11 +1287,6 @@ describe("TokenService", () => {
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
// set refresh token migration flag to false
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, false]);
// Act
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
@ -1335,11 +1312,6 @@ describe("TokenService", () => {
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
// set access token migration flag to false
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.stateSubject.next([userIdFromAccessToken, false]);
// Act
const result = await tokenService.getRefreshToken();

View File

@ -32,7 +32,6 @@ import {
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
export enum TokenStorageLocation {
@ -441,9 +440,6 @@ export class TokenService implements TokenServiceAbstraction {
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
// Set flag to indicate that the refresh token has been migrated to secure storage (don't remove this)
await this.setRefreshTokenMigratedToSecureStorage(userId);
return;
case TokenStorageLocation.Disk:
@ -467,12 +463,6 @@ export class TokenService implements TokenServiceAbstraction {
return undefined;
}
const refreshTokenMigratedToSecureStorage =
await this.getRefreshTokenMigratedToSecureStorage(userId);
if (this.platformSupportsSecureStorage && refreshTokenMigratedToSecureStorage) {
return await this.getStringFromSecureStorage(userId, this.refreshTokenSecureStorageKey);
}
// pre-secure storage migration:
// Always read memory first b/c faster
const refreshTokenMemory = await this.getStateValueByUserIdAndKeyDef(
@ -484,13 +474,24 @@ export class TokenService implements TokenServiceAbstraction {
return refreshTokenMemory;
}
// if memory is null, read from disk
// if memory is null, read from disk and then secure storage
const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK);
if (refreshTokenDisk != null) {
return refreshTokenDisk;
}
if (this.platformSupportsSecureStorage) {
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
if (refreshTokenSecureStorage != null) {
return refreshTokenSecureStorage;
}
}
return null;
}
@ -516,18 +517,6 @@ export class TokenService implements TokenServiceAbstraction {
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
}
private async getRefreshTokenMigratedToSecureStorage(userId: UserId): Promise<boolean> {
return await firstValueFrom(
this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$,
);
}
private async setRefreshTokenMigratedToSecureStorage(userId: UserId): Promise<void> {
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.update((_) => true);
}
async setClientId(
clientId: string,
vaultTimeoutAction: VaultTimeoutAction,

View File

@ -10,7 +10,6 @@ import {
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
describe.each([
@ -18,7 +17,6 @@ describe.each([
[ACCESS_TOKEN_MEMORY, "accessTokenMemory"],
[REFRESH_TOKEN_DISK, "refreshTokenDisk"],
[REFRESH_TOKEN_MEMORY, "refreshTokenMemory"],
[REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
[EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, { user: "token" }],
[API_KEY_CLIENT_ID_DISK, "apiKeyClientIdDisk"],
[API_KEY_CLIENT_ID_MEMORY, "apiKeyClientIdMemory"],

View File

@ -30,15 +30,6 @@ export const REFRESH_TOKEN_MEMORY = new UserKeyDefinition<string>(TOKEN_MEMORY,
clearOn: [], // Manually handled
});
export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new UserKeyDefinition<boolean>(
TOKEN_DISK,
"refreshTokenMigratedToSecureStorage",
{
deserializer: (refreshTokenMigratedToSecureStorage) => refreshTokenMigratedToSecureStorage,
clearOn: [], // Don't clear on lock/logout so that we always check the correct place (secure storage) for the refresh token if it's been migrated
},
);
export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL = KeyDefinition.record<string, string>(
TOKEN_DISK_LOCAL,
"emailTwoFactorTokenRecord",

View File

@ -6,10 +6,6 @@ import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
import { UsernameGeneratorOptions } from "../../tools/generator/username";
import { UserId } from "../../types/guid";
import { CipherData } from "../../vault/models/data/cipher.data";
import { LocalData } from "../../vault/models/data/local.data";
import { CipherView } from "../../vault/models/view/cipher.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
import { KdfType } from "../enums";
import { Account } from "../models/domain/account";
import { EncString } from "../models/domain/enc-string";
@ -38,8 +34,6 @@ export abstract class StateService<T extends Account = Account> {
clean: (options?: StorageOptions) => Promise<UserId>;
init: (initOptions?: InitOptions) => Promise<void>;
getAddEditCipherInfo: (options?: StorageOptions) => Promise<AddEditCipherInfo>;
setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise<void>;
/**
* Gets the user's auto key
*/
@ -104,8 +98,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated For migration purposes only, use setUserKeyBiometric instead
*/
setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise<void>;
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
getDecryptedPasswordGenerationHistory: (
options?: StorageOptions,
) => Promise<GeneratedPasswordHistory[]>;
@ -134,11 +126,6 @@ export abstract class StateService<T extends Account = Account> {
value: boolean,
options?: StorageOptions,
) => Promise<void>;
getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>;
setEncryptedCiphers: (
value: { [id: string]: CipherData },
options?: StorageOptions,
) => Promise<void>;
getEncryptedPasswordGenerationHistory: (
options?: StorageOptions,
) => Promise<GeneratedPasswordHistory[]>;
@ -165,11 +152,6 @@ export abstract class StateService<T extends Account = Account> {
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
getLastSync: (options?: StorageOptions) => Promise<string>;
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
getLocalData: (options?: StorageOptions) => Promise<{ [cipherId: string]: LocalData }>;
setLocalData: (
value: { [cipherId: string]: LocalData },
options?: StorageOptions,
) => Promise<void>;
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;

View File

@ -8,9 +8,6 @@ import {
} from "../../../tools/generator/password";
import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options";
import { DeepJsonify } from "../../../types/deep-jsonify";
import { CipherData } from "../../../vault/models/data/cipher.data";
import { CipherView } from "../../../vault/models/view/cipher.view";
import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info";
import { KdfType } from "../../enums";
import { Utils } from "../../misc/utils";
@ -61,28 +58,17 @@ export class DataEncryptionPair<TEncrypted, TDecrypted> {
}
export class AccountData {
ciphers?: DataEncryptionPair<CipherData, CipherView> = new DataEncryptionPair<
CipherData,
CipherView
>();
localData?: any;
passwordGenerationHistory?: EncryptionPair<
GeneratedPasswordHistory[],
GeneratedPasswordHistory[]
> = new EncryptionPair<GeneratedPasswordHistory[], GeneratedPasswordHistory[]>();
addEditCipherInfo?: AddEditCipherInfo;
static fromJSON(obj: DeepJsonify<AccountData>): AccountData {
if (obj == null) {
return null;
}
return Object.assign(new AccountData(), obj, {
addEditCipherInfo: {
cipher: CipherView.fromJSON(obj?.addEditCipherInfo?.cipher),
collectionIds: obj?.addEditCipherInfo?.collectionIds,
},
});
return Object.assign(new AccountData(), obj);
}
}

View File

@ -9,10 +9,6 @@ import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
import { UsernameGeneratorOptions } from "../../tools/generator/username";
import { UserId } from "../../types/guid";
import { CipherData } from "../../vault/models/data/cipher.data";
import { LocalData } from "../../vault/models/data/local.data";
import { CipherView } from "../../vault/models/view/cipher.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
import { EnvironmentService } from "../abstractions/environment.service";
import { LogService } from "../abstractions/log.service";
import {
@ -221,34 +217,6 @@ export class StateService<
return currentUser as UserId;
}
async getAddEditCipherInfo(options?: StorageOptions): Promise<AddEditCipherInfo> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
// ensure prototype on cipher
const raw = account?.data?.addEditCipherInfo;
return raw == null
? null
: {
cipher:
raw?.cipher.toJSON != null
? raw.cipher
: CipherView.fromJSON(raw?.cipher as Jsonify<CipherView>),
collectionIds: raw?.collectionIds,
};
}
async setAddEditCipherInfo(value: AddEditCipherInfo, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
account.data.addEditCipherInfo = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
}
/**
* user key when using the "never" option of vault timeout
*/
@ -465,24 +433,6 @@ export class StateService<
await this.saveSecureStorageKey(partialKeys.biometricKey, value, options);
}
@withPrototypeForArrayMembers(CipherView, CipherView.fromJSON)
async getDecryptedCiphers(options?: StorageOptions): Promise<CipherView[]> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.data?.ciphers?.decrypted;
}
async setDecryptedCiphers(value: CipherView[], options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
account.data.ciphers.decrypted = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
}
@withPrototypeForArrayMembers(GeneratedPasswordHistory)
async getDecryptedPasswordGenerationHistory(
options?: StorageOptions,
@ -621,27 +571,6 @@ export class StateService<
);
}
@withPrototypeForObjectValues(CipherData)
async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
)?.data?.ciphers?.encrypted;
}
async setEncryptedCiphers(
value: { [id: string]: CipherData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
);
account.data.ciphers.encrypted = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
);
}
/**
* @deprecated Use UserKey instead
*/
@ -805,26 +734,6 @@ export class StateService<
);
}
async getLocalData(options?: StorageOptions): Promise<{ [cipherId: string]: LocalData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.data?.localData;
}
async setLocalData(
value: { [cipherId: string]: LocalData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
account.data.localData = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@ -1510,50 +1419,3 @@ function withPrototypeForArrayMembers<T>(
};
};
}
function withPrototypeForObjectValues<T>(
valuesConstructor: new (...args: any[]) => T,
valuesConverter: (input: any) => T = (i) => i,
): (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) => { value: (...args: any[]) => Promise<{ [key: string]: T }> } {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
return {
value: function (...args: any[]) {
const originalResult: Promise<{ [key: string]: T }> = originalMethod.apply(this, args);
if (!Utils.isPromise(originalResult)) {
throw new Error(
`Error applying prototype to stored value -- result is not a promise for method ${String(
propertyKey,
)}`,
);
}
return originalResult.then((result) => {
if (result == null) {
return null;
} else {
for (const [key, val] of Object.entries(result)) {
result[key] =
val == null || val.constructor.name === valuesConstructor.prototype.constructor.name
? valuesConverter(val)
: valuesConverter(
Object.create(
valuesConstructor.prototype,
Object.getOwnPropertyDescriptors(val),
),
);
}
return result as { [key: string]: T };
}
});
},
};
};
}

View File

@ -135,3 +135,8 @@ export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk",
});
export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory");
export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory");
export const CIPHERS_DISK = new StateDefinition("ciphers", "disk", { web: "memory" });
export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", {
web: "disk-local",
});
export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory");

View File

@ -866,16 +866,6 @@ export class ApiService implements ApiServiceAbstraction {
return r;
}
async putGroupUsers(organizationId: string, id: string, request: string[]): Promise<any> {
await this.send(
"PUT",
"/organizations/" + organizationId + "/groups/" + id + "/users",
request,
true,
false,
);
}
deleteGroupUser(organizationId: string, id: string, organizationUserId: string): Promise<any> {
return this.send(
"DELETE",

View File

@ -53,6 +53,8 @@ import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-m
import { SendMigrator } from "./migrations/54-move-encrypted-sends";
import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider";
import { AuthRequestMigrator } from "./migrations/56-move-auth-requests";
import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-state-provider";
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
@ -60,8 +62,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 56;
export const CURRENT_VERSION = 58;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@ -119,7 +120,9 @@ export function createMigrationBuilder() {
.with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53)
.with(SendMigrator, 53, 54)
.with(MoveMasterKeyStateToProviderMigrator, 54, 55)
.with(AuthRequestMigrator, 55, CURRENT_VERSION);
.with(AuthRequestMigrator, 55, 56)
.with(CipherServiceMigrator, 56, 57)
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, CURRENT_VERSION);
}
export async function currentVersion(

View File

@ -0,0 +1,170 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
CIPHERS_DISK,
CIPHERS_DISK_LOCAL,
CipherServiceMigrator,
} from "./57-move-cipher-service-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2"],
user1: {
data: {
localData: {
"6865ba55-7966-4d63-b743-b12000d49631": {
lastUsedDate: 1708950970632,
},
"f895f099-6739-4cca-9d61-b12200d04bfa": {
lastUsedDate: 1709031916943,
},
},
ciphers: {
"cipher-id-10": {
id: "cipher-id-10",
},
"cipher-id-11": {
id: "cipher-id-11",
},
},
},
},
user2: {
data: {
otherStuff: "otherStuff5",
},
},
};
}
function rollbackJSON() {
return {
user_user1_ciphersLocal_localData: {
"6865ba55-7966-4d63-b743-b12000d49631": {
lastUsedDate: 1708950970632,
},
"f895f099-6739-4cca-9d61-b12200d04bfa": {
lastUsedDate: 1709031916943,
},
},
user_user1_ciphers_ciphers: {
"cipher-id-10": {
id: "cipher-id-10",
},
"cipher-id-11": {
id: "cipher-id-11",
},
},
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2"],
user1: {
data: {},
},
user2: {
data: {
localData: {
otherStuff: "otherStuff3",
},
ciphers: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
},
};
}
describe("CipherServiceMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: CipherServiceMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 56);
sut = new CipherServiceMigrator(56, 57);
});
it("should remove local data and ciphers from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
data: {},
});
});
it("should migrate localData and ciphers to state provider for accounts that have the data", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user1", CIPHERS_DISK_LOCAL, {
"6865ba55-7966-4d63-b743-b12000d49631": {
lastUsedDate: 1708950970632,
},
"f895f099-6739-4cca-9d61-b12200d04bfa": {
lastUsedDate: 1709031916943,
},
});
expect(helper.setToUser).toHaveBeenCalledWith("user1", CIPHERS_DISK, {
"cipher-id-10": {
id: "cipher-id-10",
},
"cipher-id-11": {
id: "cipher-id-11",
},
});
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", CIPHERS_DISK_LOCAL, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", CIPHERS_DISK, any());
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 57);
sut = new CipherServiceMigrator(56, 57);
});
it.each(["user1", "user2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, CIPHERS_DISK_LOCAL, null);
expect(helper.setToUser).toHaveBeenCalledWith(userId, CIPHERS_DISK, null);
});
it("should add back localData and ciphers to all accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
data: {
localData: {
"6865ba55-7966-4d63-b743-b12000d49631": {
lastUsedDate: 1708950970632,
},
"f895f099-6739-4cca-9d61-b12200d04bfa": {
lastUsedDate: 1709031916943,
},
},
ciphers: {
"cipher-id-10": {
id: "cipher-id-10",
},
"cipher-id-11": {
id: "cipher-id-11",
},
},
},
});
});
it("should not add data back if data wasn't migrated or acct doesn't exist", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
});
});
});

View File

@ -0,0 +1,79 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
data: {
localData?: unknown;
ciphers?: unknown;
};
};
export const CIPHERS_DISK_LOCAL: KeyDefinitionLike = {
key: "localData",
stateDefinition: {
name: "ciphersLocal",
},
};
export const CIPHERS_DISK: KeyDefinitionLike = {
key: "ciphers",
stateDefinition: {
name: "ciphers",
},
};
export class CipherServiceMigrator extends Migrator<56, 57> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
//Migrate localData
const localData = account?.data?.localData;
if (localData != null) {
await helper.setToUser(userId, CIPHERS_DISK_LOCAL, localData);
delete account.data.localData;
updatedAccount = true;
}
//Migrate ciphers
const ciphers = account?.data?.ciphers;
if (ciphers != null) {
await helper.setToUser(userId, CIPHERS_DISK, ciphers);
delete account.data.ciphers;
updatedAccount = true;
}
if (updatedAccount) {
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
//rollback localData
const localData = await helper.getFromUser(userId, CIPHERS_DISK_LOCAL);
if (account.data && localData != null) {
account.data.localData = localData;
await helper.set(userId, account);
}
await helper.setToUser(userId, CIPHERS_DISK_LOCAL, null);
//rollback ciphers
const ciphers = await helper.getFromUser(userId, CIPHERS_DISK);
if (account.data && ciphers != null) {
account.data.ciphers = ciphers;
await helper.set(userId, account);
}
await helper.setToUser(userId, CIPHERS_DISK, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@ -0,0 +1,72 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { IRREVERSIBLE } from "../migrator";
import {
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
RemoveRefreshTokenMigratedFlagMigrator,
} from "./58-remove-refresh-token-migrated-state-provider-flag";
// Represents data in state service pre-migration
function preMigrationJson() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user_user1_token_refreshTokenMigratedToSecureStorage: true,
user_user2_token_refreshTokenMigratedToSecureStorage: false,
};
}
function rollbackJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
};
}
describe("RemoveRefreshTokenMigratedFlagMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RemoveRefreshTokenMigratedFlagMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(preMigrationJson(), 57);
sut = new RemoveRefreshTokenMigratedFlagMigrator(57, 58);
});
it("should remove refreshTokenMigratedToSecureStorage from state provider for all accounts that have it", async () => {
await sut.migrate(helper);
expect(helper.removeFromUser).toHaveBeenCalledWith(
"user1",
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
);
expect(helper.removeFromUser).toHaveBeenCalledWith(
"user2",
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
);
expect(helper.removeFromUser).toHaveBeenCalledTimes(2);
expect(helper.removeFromUser).not.toHaveBeenCalledWith("user3", any());
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 58);
sut = new RemoveRefreshTokenMigratedFlagMigrator(57, 58);
});
it("should not add data back and throw IRREVERSIBLE error on call", async () => {
await expect(sut.rollback(helper)).rejects.toThrow(IRREVERSIBLE);
});
});
});

View File

@ -0,0 +1,34 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = NonNullable<unknown>;
export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE: KeyDefinitionLike = {
key: "refreshTokenMigratedToSecureStorage", // matches KeyDefinition.key in DeviceTrustCryptoService
stateDefinition: {
name: "token", // matches StateDefinition.name in StateDefinitions
},
};
export class RemoveRefreshTokenMigratedFlagMigrator extends Migrator<57, 58> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const refreshTokenMigratedFlag = await helper.getFromUser(
userId,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
);
if (refreshTokenMigratedFlag != null) {
// Only delete the flag if it exists
await helper.removeFromUser(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@ -1,3 +1,5 @@
import { Observable } from "rxjs";
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
@ -7,8 +9,13 @@ import { Cipher } from "../models/domain/cipher";
import { Field } from "../models/domain/field";
import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
export abstract class CipherService {
/**
* An observable monitoring the add/edit cipher info saved to memory.
*/
addEditCipherInfo$: Observable<AddEditCipherInfo>;
clearCache: (userId?: string) => Promise<void>;
encrypt: (
model: CipherView,
@ -102,4 +109,5 @@ export abstract class CipherService {
asAdmin?: boolean,
) => Promise<void>;
getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise<any>;
setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise<void>;
}

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherResponse } from "../response/cipher.response";
@ -84,4 +86,8 @@ export class CipherData {
this.passwordHistory = response.passwordHistory.map((ph) => new PasswordHistoryData(ph));
}
}
static fromJSON(obj: Jsonify<CipherData>) {
return Object.assign(new CipherData(), obj);
}
}

View File

@ -21,6 +21,10 @@ export class CollectionDetailsResponse extends CollectionResponse {
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response: any) {
@ -35,15 +39,10 @@ export class CollectionDetailsResponse extends CollectionResponse {
}
}
export class CollectionAccessDetailsResponse extends CollectionResponse {
export class CollectionAccessDetailsResponse extends CollectionDetailsResponse {
groups: SelectionReadOnlyResponse[] = [];
users: SelectionReadOnlyResponse[] = [];
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response: any) {
super(response);
this.assigned = this.getResponseProperty("Assigned") || false;

View File

@ -1,6 +1,8 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { makeStaticByteArray } from "../../../spec/utils";
import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service";
@ -12,10 +14,12 @@ import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../platform/services/container.service";
import { UserId } from "../../types/guid";
import { CipherKey, OrgKey } from "../../types/key";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
@ -97,6 +101,8 @@ const cipherData: CipherData = {
},
],
};
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
describe("Cipher Service", () => {
const cryptoService = mock<CryptoService>();
@ -109,6 +115,8 @@ describe("Cipher Service", () => {
const searchService = mock<SearchService>();
const encryptService = mock<EncryptService>();
const configService = mock<ConfigService>();
accountService = mockAccountServiceWith(mockUserId);
const stateProvider = new FakeStateProvider(accountService);
let cipherService: CipherService;
let cipherObj: Cipher;
@ -130,6 +138,7 @@ describe("Cipher Service", () => {
encryptService,
cipherFileUploadService,
configService,
stateProvider,
);
cipherObj = new Cipher(cipherData);

View File

@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { Observable, firstValueFrom } from "rxjs";
import { SemVer } from "semver";
import { ApiService } from "../../abstractions/api.service";
@ -21,13 +21,15 @@ import Domain from "../../platform/models/domain/domain-base";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { ActiveUserState, StateProvider } from "../../platform/state";
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
import { OrgKey, UserKey } from "../../types/key";
import { UserKey, OrgKey } from "../../types/key";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type";
import { CipherData } from "../models/data/cipher.data";
import { LocalData } from "../models/data/local.data";
import { Attachment } from "../models/domain/attachment";
import { Card } from "../models/domain/card";
import { Cipher } from "../models/domain/cipher";
@ -54,6 +56,14 @@ import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
import { PasswordHistoryView } from "../models/view/password-history.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
import {
ENCRYPTED_CIPHERS,
LOCAL_DATA_KEY,
ADD_EDIT_CIPHER_INFO_KEY,
DECRYPTED_CIPHERS,
} from "./key-state/ciphers.state";
const CIPHER_KEY_ENC_MIN_SERVER_VER = new SemVer("2024.2.0");
@ -62,6 +72,16 @@ export class CipherService implements CipherServiceAbstraction {
this.sortCiphersByLastUsed,
);
localData$: Observable<Record<CipherId, LocalData>>;
ciphers$: Observable<Record<CipherId, CipherData>>;
cipherViews$: Observable<Record<CipherId, CipherView>>;
addEditCipherInfo$: Observable<AddEditCipherInfo>;
private localDataState: ActiveUserState<Record<CipherId, LocalData>>;
private encryptedCiphersState: ActiveUserState<Record<CipherId, CipherData>>;
private decryptedCiphersState: ActiveUserState<Record<CipherId, CipherView>>;
private addEditCipherInfoState: ActiveUserState<AddEditCipherInfo>;
constructor(
private cryptoService: CryptoService,
private domainSettingsService: DomainSettingsService,
@ -73,11 +93,17 @@ export class CipherService implements CipherServiceAbstraction {
private encryptService: EncryptService,
private cipherFileUploadService: CipherFileUploadService,
private configService: ConfigService,
) {}
private stateProvider: StateProvider,
) {
this.localDataState = this.stateProvider.getActive(LOCAL_DATA_KEY);
this.encryptedCiphersState = this.stateProvider.getActive(ENCRYPTED_CIPHERS);
this.decryptedCiphersState = this.stateProvider.getActive(DECRYPTED_CIPHERS);
this.addEditCipherInfoState = this.stateProvider.getActive(ADD_EDIT_CIPHER_INFO_KEY);
async getDecryptedCipherCache(): Promise<CipherView[]> {
const decryptedCiphers = await this.stateService.getDecryptedCiphers();
return decryptedCiphers;
this.localData$ = this.localDataState.state$;
this.ciphers$ = this.encryptedCiphersState.state$;
this.cipherViews$ = this.decryptedCiphersState.state$;
this.addEditCipherInfo$ = this.addEditCipherInfoState.state$;
}
async setDecryptedCipherCache(value: CipherView[]) {
@ -85,7 +111,7 @@ export class CipherService implements CipherServiceAbstraction {
// if we cache it then we may accidentially return it when it's not right, we'd rather try decryption again.
// We still want to set null though, that is the indicator that the cache isn't valid and we should do decryption.
if (value == null || value.length !== 0) {
await this.stateService.setDecryptedCiphers(value);
await this.setDecryptedCiphers(value);
}
if (this.searchService != null) {
if (value == null) {
@ -96,6 +122,14 @@ export class CipherService implements CipherServiceAbstraction {
}
}
private async setDecryptedCiphers(value: CipherView[]) {
const cipherViews: { [id: string]: CipherView } = {};
value?.forEach((c) => {
cipherViews[c.id] = c;
});
await this.decryptedCiphersState.update(() => cipherViews);
}
async clearCache(userId?: string): Promise<void> {
await this.clearDecryptedCiphersState(userId);
}
@ -268,24 +302,27 @@ export class CipherService implements CipherServiceAbstraction {
}
async get(id: string): Promise<Cipher> {
const ciphers = await this.stateService.getEncryptedCiphers();
const ciphers = await firstValueFrom(this.ciphers$);
// eslint-disable-next-line
if (ciphers == null || !ciphers.hasOwnProperty(id)) {
return null;
}
const localData = await this.stateService.getLocalData();
return new Cipher(ciphers[id], localData ? localData[id] : null);
const localData = await firstValueFrom(this.localData$);
const cipherId = id as CipherId;
return new Cipher(ciphers[cipherId], localData ? localData[cipherId] : null);
}
async getAll(): Promise<Cipher[]> {
const localData = await this.stateService.getLocalData();
const ciphers = await this.stateService.getEncryptedCiphers();
const localData = await firstValueFrom(this.localData$);
const ciphers = await firstValueFrom(this.ciphers$);
const response: Cipher[] = [];
for (const id in ciphers) {
// eslint-disable-next-line
if (ciphers.hasOwnProperty(id)) {
response.push(new Cipher(ciphers[id], localData ? localData[id] : null));
const cipherId = id as CipherId;
response.push(new Cipher(ciphers[cipherId], localData ? localData[cipherId] : null));
}
}
return response;
@ -293,12 +330,23 @@ export class CipherService implements CipherServiceAbstraction {
@sequentialize(() => "getAllDecrypted")
async getAllDecrypted(): Promise<CipherView[]> {
if ((await this.getDecryptedCipherCache()) != null) {
let decCiphers = await this.getDecryptedCiphers();
if (decCiphers != null && decCiphers.length !== 0) {
await this.reindexCiphers();
return await this.getDecryptedCipherCache();
return await this.getDecryptedCiphers();
}
const ciphers = await this.getAll();
decCiphers = await this.decryptCiphers(await this.getAll());
await this.setDecryptedCipherCache(decCiphers);
return decCiphers;
}
private async getDecryptedCiphers() {
return Object.values(await firstValueFrom(this.cipherViews$));
}
private async decryptCiphers(ciphers: Cipher[]) {
const orgKeys = await this.cryptoService.getOrgKeys();
const userKey = await this.cryptoService.getUserKeyWithLegacySupport();
if (Object.keys(orgKeys).length === 0 && userKey == null) {
@ -326,7 +374,6 @@ export class CipherService implements CipherServiceAbstraction {
.flat()
.sort(this.getLocaleSortingFunction());
await this.setDecryptedCipherCache(decCiphers);
return decCiphers;
}
@ -336,7 +383,7 @@ export class CipherService implements CipherServiceAbstraction {
this.searchService != null &&
((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId;
if (reindexRequired) {
await this.searchService.indexCiphers(await this.getDecryptedCipherCache(), userId);
await this.searchService.indexCiphers(await this.getDecryptedCiphers(), userId);
}
}
@ -448,22 +495,24 @@ export class CipherService implements CipherServiceAbstraction {
}
async updateLastUsedDate(id: string): Promise<void> {
let ciphersLocalData = await this.stateService.getLocalData();
let ciphersLocalData = await firstValueFrom(this.localData$);
if (!ciphersLocalData) {
ciphersLocalData = {};
}
if (ciphersLocalData[id]) {
ciphersLocalData[id].lastUsedDate = new Date().getTime();
const cipherId = id as CipherId;
if (ciphersLocalData[cipherId]) {
ciphersLocalData[cipherId].lastUsedDate = new Date().getTime();
} else {
ciphersLocalData[id] = {
ciphersLocalData[cipherId] = {
lastUsedDate: new Date().getTime(),
};
}
await this.stateService.setLocalData(ciphersLocalData);
await this.localDataState.update(() => ciphersLocalData);
const decryptedCipherCache = await this.stateService.getDecryptedCiphers();
const decryptedCipherCache = await this.getDecryptedCiphers();
if (!decryptedCipherCache) {
return;
}
@ -471,30 +520,32 @@ export class CipherService implements CipherServiceAbstraction {
for (let i = 0; i < decryptedCipherCache.length; i++) {
const cached = decryptedCipherCache[i];
if (cached.id === id) {
cached.localData = ciphersLocalData[id];
cached.localData = ciphersLocalData[id as CipherId];
break;
}
}
await this.stateService.setDecryptedCiphers(decryptedCipherCache);
await this.setDecryptedCiphers(decryptedCipherCache);
}
async updateLastLaunchedDate(id: string): Promise<void> {
let ciphersLocalData = await this.stateService.getLocalData();
let ciphersLocalData = await firstValueFrom(this.localData$);
if (!ciphersLocalData) {
ciphersLocalData = {};
}
if (ciphersLocalData[id]) {
ciphersLocalData[id].lastLaunched = new Date().getTime();
const cipherId = id as CipherId;
if (ciphersLocalData[cipherId]) {
ciphersLocalData[cipherId].lastLaunched = new Date().getTime();
} else {
ciphersLocalData[id] = {
ciphersLocalData[cipherId] = {
lastUsedDate: new Date().getTime(),
};
}
await this.stateService.setLocalData(ciphersLocalData);
await this.localDataState.update(() => ciphersLocalData);
const decryptedCipherCache = await this.stateService.getDecryptedCiphers();
const decryptedCipherCache = await this.getDecryptedCiphers();
if (!decryptedCipherCache) {
return;
}
@ -502,11 +553,11 @@ export class CipherService implements CipherServiceAbstraction {
for (let i = 0; i < decryptedCipherCache.length; i++) {
const cached = decryptedCipherCache[i];
if (cached.id === id) {
cached.localData = ciphersLocalData[id];
cached.localData = ciphersLocalData[id as CipherId];
break;
}
}
await this.stateService.setDecryptedCiphers(decryptedCipherCache);
await this.setDecryptedCiphers(decryptedCipherCache);
}
async saveNeverDomain(domain: string): Promise<void> {
@ -711,7 +762,7 @@ export class CipherService implements CipherServiceAbstraction {
await this.apiService.send("POST", "/ciphers/bulk-collections", request, true, false);
// Update the local state
const ciphers = await this.stateService.getEncryptedCiphers();
const ciphers = await firstValueFrom(this.ciphers$);
for (const id of cipherIds) {
const cipher = ciphers[id];
@ -728,30 +779,29 @@ export class CipherService implements CipherServiceAbstraction {
}
await this.clearCache();
await this.stateService.setEncryptedCiphers(ciphers);
await this.encryptedCiphersState.update(() => ciphers);
}
async upsert(cipher: CipherData | CipherData[]): Promise<any> {
let ciphers = await this.stateService.getEncryptedCiphers();
if (ciphers == null) {
ciphers = {};
}
if (cipher instanceof CipherData) {
const c = cipher as CipherData;
ciphers[c.id] = c;
} else {
(cipher as CipherData[]).forEach((c) => {
ciphers[c.id] = c;
});
}
await this.replace(ciphers);
const ciphers = cipher instanceof CipherData ? [cipher] : cipher;
await this.updateEncryptedCipherState((current) => {
ciphers.forEach((c) => current[c.id as CipherId]);
return current;
});
}
async replace(ciphers: { [id: string]: CipherData }): Promise<any> {
await this.updateEncryptedCipherState(() => ciphers);
}
private async updateEncryptedCipherState(
update: (current: Record<CipherId, CipherData>) => Record<CipherId, CipherData>,
) {
await this.clearDecryptedCiphersState();
await this.stateService.setEncryptedCiphers(ciphers);
await this.encryptedCiphersState.update((current) => {
const result = update(current ?? {});
return result;
});
}
async clear(userId?: string): Promise<any> {
@ -762,7 +812,7 @@ export class CipherService implements CipherServiceAbstraction {
async moveManyWithServer(ids: string[], folderId: string): Promise<any> {
await this.apiService.putMoveCiphers(new CipherBulkMoveRequest(ids, folderId));
let ciphers = await this.stateService.getEncryptedCiphers();
let ciphers = await firstValueFrom(this.ciphers$);
if (ciphers == null) {
ciphers = {};
}
@ -770,33 +820,34 @@ export class CipherService implements CipherServiceAbstraction {
ids.forEach((id) => {
// eslint-disable-next-line
if (ciphers.hasOwnProperty(id)) {
ciphers[id].folderId = folderId;
ciphers[id as CipherId].folderId = folderId;
}
});
await this.clearCache();
await this.stateService.setEncryptedCiphers(ciphers);
await this.encryptedCiphersState.update(() => ciphers);
}
async delete(id: string | string[]): Promise<any> {
const ciphers = await this.stateService.getEncryptedCiphers();
const ciphers = await firstValueFrom(this.ciphers$);
if (ciphers == null) {
return;
}
if (typeof id === "string") {
if (ciphers[id] == null) {
const cipherId = id as CipherId;
if (ciphers[cipherId] == null) {
return;
}
delete ciphers[id];
delete ciphers[cipherId];
} else {
(id as string[]).forEach((i) => {
(id as CipherId[]).forEach((i) => {
delete ciphers[i];
});
}
await this.clearCache();
await this.stateService.setEncryptedCiphers(ciphers);
await this.encryptedCiphersState.update(() => ciphers);
}
async deleteWithServer(id: string, asAdmin = false): Promise<any> {
@ -820,21 +871,26 @@ export class CipherService implements CipherServiceAbstraction {
}
async deleteAttachment(id: string, attachmentId: string): Promise<void> {
const ciphers = await this.stateService.getEncryptedCiphers();
let ciphers = await firstValueFrom(this.ciphers$);
const cipherId = id as CipherId;
// eslint-disable-next-line
if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[id].attachments == null) {
if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[cipherId].attachments == null) {
return;
}
for (let i = 0; i < ciphers[id].attachments.length; i++) {
if (ciphers[id].attachments[i].id === attachmentId) {
ciphers[id].attachments.splice(i, 1);
for (let i = 0; i < ciphers[cipherId].attachments.length; i++) {
if (ciphers[cipherId].attachments[i].id === attachmentId) {
ciphers[cipherId].attachments.splice(i, 1);
}
}
await this.clearCache();
await this.stateService.setEncryptedCiphers(ciphers);
await this.encryptedCiphersState.update(() => {
if (ciphers == null) {
ciphers = {};
}
return ciphers;
});
}
async deleteAttachmentWithServer(id: string, attachmentId: string): Promise<void> {
@ -917,12 +973,12 @@ export class CipherService implements CipherServiceAbstraction {
}
async softDelete(id: string | string[]): Promise<any> {
const ciphers = await this.stateService.getEncryptedCiphers();
let ciphers = await firstValueFrom(this.ciphers$);
if (ciphers == null) {
return;
}
const setDeletedDate = (cipherId: string) => {
const setDeletedDate = (cipherId: CipherId) => {
if (ciphers[cipherId] == null) {
return;
}
@ -930,13 +986,18 @@ export class CipherService implements CipherServiceAbstraction {
};
if (typeof id === "string") {
setDeletedDate(id);
setDeletedDate(id as CipherId);
} else {
(id as string[]).forEach(setDeletedDate);
}
await this.clearCache();
await this.stateService.setEncryptedCiphers(ciphers);
await this.encryptedCiphersState.update(() => {
if (ciphers == null) {
ciphers = {};
}
return ciphers;
});
}
async softDeleteWithServer(id: string, asAdmin = false): Promise<any> {
@ -963,17 +1024,18 @@ export class CipherService implements CipherServiceAbstraction {
async restore(
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
) {
const ciphers = await this.stateService.getEncryptedCiphers();
let ciphers = await firstValueFrom(this.ciphers$);
if (ciphers == null) {
return;
}
const clearDeletedDate = (c: { id: string; revisionDate: string }) => {
if (ciphers[c.id] == null) {
const cipherId = c.id as CipherId;
if (ciphers[cipherId] == null) {
return;
}
ciphers[c.id].deletedDate = null;
ciphers[c.id].revisionDate = c.revisionDate;
ciphers[cipherId].deletedDate = null;
ciphers[cipherId].revisionDate = c.revisionDate;
};
if (cipher.constructor.name === Array.name) {
@ -983,7 +1045,12 @@ export class CipherService implements CipherServiceAbstraction {
}
await this.clearCache();
await this.stateService.setEncryptedCiphers(ciphers);
await this.encryptedCiphersState.update(() => {
if (ciphers == null) {
ciphers = {};
}
return ciphers;
});
}
async restoreWithServer(id: string, asAdmin = false): Promise<any> {
@ -1025,6 +1092,10 @@ export class CipherService implements CipherServiceAbstraction {
);
}
async setAddEditCipherInfo(value: AddEditCipherInfo) {
await this.addEditCipherInfoState.update(() => value);
}
// Helpers
// In the case of a cipher that is being shared with an organization, we want to decrypt the
@ -1350,11 +1421,11 @@ export class CipherService implements CipherServiceAbstraction {
}
private async clearEncryptedCiphersState(userId?: string) {
await this.stateService.setEncryptedCiphers(null, { userId: userId });
await this.encryptedCiphersState.update(() => ({}));
}
private async clearDecryptedCiphersState(userId?: string) {
await this.stateService.setDecryptedCiphers(null, { userId: userId });
await this.setDecryptedCiphers(null);
this.clearSortedCiphers();
}

View File

@ -8,7 +8,6 @@ import { FakeStateProvider } from "../../../../spec/fake-state-provider";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
@ -27,7 +26,6 @@ describe("Folder Service", () => {
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let cipherService: MockProxy<CipherService>;
let stateService: MockProxy<StateService>;
let stateProvider: FakeStateProvider;
const mockUserId = Utils.newGuid() as UserId;
@ -39,7 +37,6 @@ describe("Folder Service", () => {
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
cipherService = mock<CipherService>();
stateService = mock<StateService>();
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
@ -52,13 +49,7 @@ describe("Folder Service", () => {
);
encryptService.decryptToUtf8.mockResolvedValue("DEC");
folderService = new FolderService(
cryptoService,
i18nService,
cipherService,
stateService,
stateProvider,
);
folderService = new FolderService(cryptoService, i18nService, cipherService, stateProvider);
folderState = stateProvider.activeUser.getFake(FOLDER_ENCRYPTED_FOLDERS);

View File

@ -2,17 +2,16 @@ import { Observable, firstValueFrom, map } from "rxjs";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { CipherData } from "../../../vault/models/data/cipher.data";
import { FolderData } from "../../../vault/models/data/folder.data";
import { Folder } from "../../../vault/models/domain/folder";
import { FolderView } from "../../../vault/models/view/folder.view";
import { Cipher } from "../../models/domain/cipher";
import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state";
export class FolderService implements InternalFolderServiceAbstraction {
@ -26,7 +25,6 @@ export class FolderService implements InternalFolderServiceAbstraction {
private cryptoService: CryptoService,
private i18nService: I18nService,
private cipherService: CipherService,
private stateService: StateService,
private stateProvider: StateProvider,
) {
this.encryptedFoldersState = this.stateProvider.getActive(FOLDER_ENCRYPTED_FOLDERS);
@ -144,9 +142,9 @@ export class FolderService implements InternalFolderServiceAbstraction {
});
// Items in a deleted folder are re-assigned to "No Folder"
const ciphers = await this.stateService.getEncryptedCiphers();
const ciphers = await this.cipherService.getAll();
if (ciphers != null) {
const updates: CipherData[] = [];
const updates: Cipher[] = [];
for (const cId in ciphers) {
if (ciphers[cId].folderId === id) {
ciphers[cId].folderId = null;
@ -156,7 +154,7 @@ export class FolderService implements InternalFolderServiceAbstraction {
if (updates.length > 0) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.cipherService.upsert(updates);
this.cipherService.upsert(updates.map((c) => c.toCipherData()));
}
}
}

View File

@ -0,0 +1,52 @@
import { Jsonify } from "type-fest";
import {
CIPHERS_DISK,
CIPHERS_DISK_LOCAL,
CIPHERS_MEMORY,
KeyDefinition,
} from "../../../platform/state";
import { CipherId } from "../../../types/guid";
import { CipherData } from "../../models/data/cipher.data";
import { LocalData } from "../../models/data/local.data";
import { CipherView } from "../../models/view/cipher.view";
import { AddEditCipherInfo } from "../../types/add-edit-cipher-info";
export const ENCRYPTED_CIPHERS = KeyDefinition.record<CipherData>(CIPHERS_DISK, "ciphers", {
deserializer: (obj: Jsonify<CipherData>) => CipherData.fromJSON(obj),
});
export const DECRYPTED_CIPHERS = KeyDefinition.record<CipherView>(
CIPHERS_MEMORY,
"decryptedCiphers",
{
deserializer: (cipher: Jsonify<CipherView>) => CipherView.fromJSON(cipher),
},
);
export const LOCAL_DATA_KEY = new KeyDefinition<Record<CipherId, LocalData>>(
CIPHERS_DISK_LOCAL,
"localData",
{
deserializer: (localData) => localData,
},
);
export const ADD_EDIT_CIPHER_INFO_KEY = new KeyDefinition<AddEditCipherInfo>(
CIPHERS_MEMORY,
"addEditCipherInfo",
{
deserializer: (addEditCipherInfo: AddEditCipherInfo) => {
if (addEditCipherInfo == null) {
return null;
}
const cipher =
addEditCipherInfo?.cipher.toJSON != null
? addEditCipherInfo.cipher
: CipherView.fromJSON(addEditCipherInfo?.cipher as Jsonify<CipherView>);
return { cipher, collectionIds: addEditCipherInfo.collectionIds };
},
},
);

2
package-lock.json generated
View File

@ -247,7 +247,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2024.4.0"
"version": "2024.4.1"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",