mirror of
https://github.com/bitwarden/browser
synced 2026-02-23 16:13:21 +00:00
fix conflicts
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
|
||||
|
||||
export abstract class CollectionAdminService {
|
||||
getAll: (organizationId: string) => Promise<CollectionAdminView[]>;
|
||||
get: (organizationId: string, collectionId: string) => Promise<CollectionAdminView | undefined>;
|
||||
save: (collection: CollectionAdminView) => Promise<CollectionDetailsResponse>;
|
||||
delete: (organizationId: string, collectionId: string) => Promise<void>;
|
||||
bulkAssignAccess: (
|
||||
abstract getAll(organizationId: string): Promise<CollectionAdminView[]>;
|
||||
abstract get(
|
||||
organizationId: string,
|
||||
collectionId: string,
|
||||
): Promise<CollectionAdminView | undefined>;
|
||||
abstract save(
|
||||
collection: CollectionAdminView,
|
||||
userId: UserId,
|
||||
): Promise<CollectionDetailsResponse>;
|
||||
abstract delete(organizationId: string, collectionId: string): Promise<void>;
|
||||
abstract bulkAssignAccess(
|
||||
organizationId: string,
|
||||
collectionIds: string[],
|
||||
users: CollectionAccessSelectionView[],
|
||||
groups: CollectionAccessSelectionView[],
|
||||
) => Promise<void>;
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -9,27 +7,25 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { CollectionData, Collection, CollectionView } from "../models";
|
||||
|
||||
export abstract class CollectionService {
|
||||
encryptedCollections$: Observable<Collection[]>;
|
||||
decryptedCollections$: Observable<CollectionView[]>;
|
||||
|
||||
clearActiveUserCache: () => Promise<void>;
|
||||
encrypt: (model: CollectionView) => Promise<Collection>;
|
||||
decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>;
|
||||
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
|
||||
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;
|
||||
abstract upsert(collection: CollectionData, userId: UserId): Promise<any>;
|
||||
abstract replace(collections: { [id: string]: CollectionData }, userId: UserId): Promise<any>;
|
||||
/**
|
||||
* @deprecated This method will soon be made private
|
||||
* See PM-12375
|
||||
* @deprecated This method will soon be made private, use `decryptedCollections$` instead.
|
||||
*/
|
||||
decryptMany: (
|
||||
abstract decryptMany$(
|
||||
collections: Collection[],
|
||||
orgKeys?: Record<OrganizationId, OrgKey>,
|
||||
) => Promise<CollectionView[]>;
|
||||
get: (id: string) => Promise<Collection>;
|
||||
getAll: () => Promise<Collection[]>;
|
||||
getAllDecrypted: () => Promise<CollectionView[]>;
|
||||
getAllNested: (collections?: CollectionView[]) => Promise<TreeNode<CollectionView>[]>;
|
||||
getNested: (id: string) => Promise<TreeNode<CollectionView>>;
|
||||
upsert: (collection: CollectionData | CollectionData[]) => Promise<any>;
|
||||
replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>;
|
||||
clear: (userId?: string) => Promise<void>;
|
||||
delete: (id: string | string[]) => Promise<any>;
|
||||
orgKeys: Record<OrganizationId, OrgKey>,
|
||||
): Observable<CollectionView[]>;
|
||||
abstract delete(ids: CollectionId[], userId: UserId): Promise<any>;
|
||||
abstract encrypt(model: CollectionView, userId: UserId): Promise<Collection>;
|
||||
/**
|
||||
* Transforms the input CollectionViews into TreeNodes
|
||||
*/
|
||||
abstract getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[];
|
||||
/*
|
||||
* Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id
|
||||
*/
|
||||
abstract getNested(collections: CollectionView[], id: string): TreeNode<CollectionView>;
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { CollectionData, Collection, CollectionView } from "../models";
|
||||
|
||||
export abstract class vNextCollectionService {
|
||||
encryptedCollections$: (userId: UserId) => Observable<Collection[]>;
|
||||
decryptedCollections$: (userId: UserId) => Observable<CollectionView[]>;
|
||||
upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise<any>;
|
||||
replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>;
|
||||
/**
|
||||
* Clear decrypted state without affecting encrypted state.
|
||||
* Used for locking the vault.
|
||||
*/
|
||||
clearDecryptedState: (userId: UserId) => Promise<void>;
|
||||
/**
|
||||
* Clear decrypted and encrypted state.
|
||||
* Used for logging out.
|
||||
*/
|
||||
clear: (userId: UserId) => Promise<void>;
|
||||
delete: (id: string | string[], userId: UserId) => Promise<any>;
|
||||
encrypt: (model: CollectionView) => Promise<Collection>;
|
||||
/**
|
||||
* @deprecated This method will soon be made private, use `decryptedCollections$` instead.
|
||||
*/
|
||||
decryptMany: (
|
||||
collections: Collection[],
|
||||
orgKeys?: Record<OrganizationId, OrgKey> | null,
|
||||
) => Promise<CollectionView[]>;
|
||||
/**
|
||||
* Transforms the input CollectionViews into TreeNodes
|
||||
*/
|
||||
getAllNested: (collections: CollectionView[]) => TreeNode<CollectionView>[];
|
||||
/**
|
||||
* Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id
|
||||
*/
|
||||
getNested: (collections: CollectionView[], id: string) => TreeNode<CollectionView>;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
|
||||
@@ -16,12 +14,12 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions
|
||||
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
|
||||
*/
|
||||
unmanaged: boolean;
|
||||
unmanaged: boolean = false;
|
||||
|
||||
/**
|
||||
* Flag indicating the user has been explicitly assigned to this Collection
|
||||
*/
|
||||
assigned: boolean;
|
||||
assigned: boolean = false;
|
||||
|
||||
constructor(response?: CollectionAccessDetailsResponse) {
|
||||
super(response);
|
||||
@@ -45,6 +43,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
|
||||
*/
|
||||
override canEdit(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
org?.canEditAnyCollection ||
|
||||
(this.unmanaged && org?.canEditUnmanagedCollections) ||
|
||||
@@ -56,6 +58,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Returns true if the user can delete a collection from the Admin Console.
|
||||
*/
|
||||
override canDelete(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return org?.canDeleteAnyCollection || super.canDelete(org);
|
||||
}
|
||||
|
||||
@@ -63,6 +69,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Whether the user can modify user access to this collection
|
||||
*/
|
||||
canEditUserAccess(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
|
||||
);
|
||||
@@ -72,6 +82,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Whether the user can modify group access to this collection
|
||||
*/
|
||||
canEditGroupAccess(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
|
||||
this.canEdit(org)
|
||||
@@ -82,11 +96,13 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
|
||||
*/
|
||||
override canViewCollectionInfo(org: Organization | undefined): boolean {
|
||||
if (this.isUnassignedCollection) {
|
||||
if (this.isUnassignedCollection || this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
const isAdmin = org?.isAdmin ?? false;
|
||||
const permissions = org?.permissions.editAnyCollection ?? false;
|
||||
|
||||
return this.manage || org?.isAdmin || org?.permissions.editAnyCollection;
|
||||
return this.manage || isAdmin || permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,10 @@ export class CollectionData {
|
||||
this.type = response.type;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<CollectionData>) {
|
||||
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import Domain, { EncryptableKeys } from "@bitwarden/common/platform/models/domain/domain-base";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { CollectionData } from "./collection.data";
|
||||
@@ -15,16 +13,16 @@ export const CollectionTypes = {
|
||||
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
|
||||
|
||||
export class Collection extends Domain {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: EncString;
|
||||
externalId: string;
|
||||
readOnly: boolean;
|
||||
hidePasswords: boolean;
|
||||
manage: boolean;
|
||||
type: CollectionType;
|
||||
id: string | undefined;
|
||||
organizationId: string | undefined;
|
||||
name: EncString | undefined;
|
||||
externalId: string | undefined;
|
||||
readOnly: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
manage: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(obj?: CollectionData) {
|
||||
constructor(obj?: CollectionData | null) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
return;
|
||||
@@ -51,8 +49,8 @@ export class Collection extends Domain {
|
||||
return this.decryptObj<Collection, CollectionView>(
|
||||
this,
|
||||
new CollectionView(this),
|
||||
["name"],
|
||||
this.organizationId,
|
||||
["name"] as EncryptableKeys<Collection, CollectionView>[],
|
||||
this.organizationId ?? null,
|
||||
orgKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { Collection, CollectionType } from "./collection";
|
||||
import { Collection, CollectionType, CollectionTypes } from "./collection";
|
||||
import { CollectionAccessDetailsResponse } from "./collection.response";
|
||||
|
||||
export const NestingDelimiter = "/";
|
||||
|
||||
export class CollectionView implements View, ITreeNodeObject {
|
||||
id: string = null;
|
||||
organizationId: string = null;
|
||||
name: string = null;
|
||||
externalId: string = null;
|
||||
id: string | undefined;
|
||||
organizationId: string | undefined;
|
||||
name: string = "";
|
||||
externalId: string | undefined;
|
||||
// readOnly applies to the items within a collection
|
||||
readOnly: boolean = null;
|
||||
hidePasswords: boolean = null;
|
||||
manage: boolean = null;
|
||||
assigned: boolean = null;
|
||||
type: CollectionType = null;
|
||||
readOnly: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
manage: boolean = false;
|
||||
assigned: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(c?: Collection | CollectionAccessDetailsResponse) {
|
||||
if (!c) {
|
||||
@@ -57,7 +55,11 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
|
||||
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
|
||||
*/
|
||||
canEdit(org: Organization): boolean {
|
||||
canEdit(org: Organization | undefined): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (org != null && org.id !== this.organizationId) {
|
||||
throw new Error(
|
||||
"Id of the organization provided does not match the org id of the collection.",
|
||||
@@ -71,7 +73,7 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
* Returns true if the user can delete a collection from the individual vault.
|
||||
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
|
||||
*/
|
||||
canDelete(org: Organization): boolean {
|
||||
canDelete(org: Organization | undefined): boolean {
|
||||
if (org != null && org.id !== this.organizationId) {
|
||||
throw new Error(
|
||||
"Id of the organization provided does not match the org id of the collection.",
|
||||
@@ -81,7 +83,7 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin;
|
||||
|
||||
// Only use individual permissions, not admin permissions
|
||||
return canDeleteManagedCollections && this.manage;
|
||||
return canDeleteManagedCollections && this.manage && !this.isDefaultCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,4 +96,8 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
static fromJSON(obj: Jsonify<CollectionView>) {
|
||||
return Object.assign(new CollectionView(new Collection()), obj);
|
||||
}
|
||||
|
||||
get isDefaultCollection() {
|
||||
return this.type == CollectionTypes.DefaultUserCollection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
COLLECTION_DISK,
|
||||
COLLECTION_MEMORY,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { CollectionData, CollectionView } from "../models";
|
||||
|
||||
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<
|
||||
CollectionData | null,
|
||||
CollectionId
|
||||
>(COLLECTION_DISK, "collections", {
|
||||
deserializer: (jsonData: Jsonify<CollectionData | null>) => CollectionData.fromJSON(jsonData),
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
|
||||
export const DECRYPTED_COLLECTION_DATA_KEY = new UserKeyDefinition<CollectionView[] | null>(
|
||||
COLLECTION_MEMORY,
|
||||
"decryptedCollections",
|
||||
{
|
||||
deserializer: (obj: Jsonify<CollectionView[] | null>) =>
|
||||
obj?.map((f) => CollectionView.fromJSON(f)) ?? null,
|
||||
clearOn: ["logout", "lock"],
|
||||
},
|
||||
);
|
||||
@@ -1,9 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { CollectionAdminService, CollectionService } from "../abstractions";
|
||||
@@ -55,7 +57,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
||||
return view;
|
||||
}
|
||||
|
||||
async save(collection: CollectionAdminView): Promise<CollectionDetailsResponse> {
|
||||
async save(collection: CollectionAdminView, userId: UserId): Promise<CollectionDetailsResponse> {
|
||||
const request = await this.encrypt(collection);
|
||||
|
||||
let response: CollectionDetailsResponse;
|
||||
@@ -71,9 +73,9 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
||||
}
|
||||
|
||||
if (response.assigned) {
|
||||
await this.collectionService.upsert(new CollectionData(response));
|
||||
await this.collectionService.upsert(new CollectionData(response), userId);
|
||||
} else {
|
||||
await this.collectionService.delete(collection.id);
|
||||
await this.collectionService.delete([collection.id as CollectionId], userId);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import {
|
||||
FakeStateProvider,
|
||||
@@ -16,124 +17,382 @@ import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/gu
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { CollectionData } from "../models";
|
||||
import { CollectionData, CollectionView } from "../models";
|
||||
|
||||
import {
|
||||
DefaultCollectionService,
|
||||
ENCRYPTED_COLLECTION_DATA_KEY,
|
||||
} from "./default-collection.service";
|
||||
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
|
||||
import { DefaultCollectionService } from "./default-collection.service";
|
||||
|
||||
describe("DefaultCollectionService", () => {
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
let userId: UserId;
|
||||
|
||||
let cryptoKeys: ReplaySubject<Record<OrganizationId, OrgKey> | null>;
|
||||
|
||||
let collectionService: DefaultCollectionService;
|
||||
|
||||
beforeEach(() => {
|
||||
userId = Utils.newGuid() as UserId;
|
||||
|
||||
keyService = mock();
|
||||
encryptService = mock();
|
||||
i18nService = mock();
|
||||
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
|
||||
cryptoKeys = new ReplaySubject(1);
|
||||
keyService.orgKeys$.mockReturnValue(cryptoKeys);
|
||||
|
||||
// Set up mock decryption
|
||||
encryptService.decryptString
|
||||
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey))
|
||||
.mockImplementation((encString, key) =>
|
||||
Promise.resolve(encString.data.replace("ENC_", "DEC_")),
|
||||
);
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
|
||||
// Arrange i18nService so that sorting algorithm doesn't throw
|
||||
i18nService.collator = null;
|
||||
|
||||
collectionService = new DefaultCollectionService(
|
||||
keyService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).bitwardenContainerService;
|
||||
});
|
||||
|
||||
describe("decryptedCollections$", () => {
|
||||
it("emits decrypted collections from state", async () => {
|
||||
// Arrange test collections
|
||||
// Arrange test data
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
|
||||
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
|
||||
const collection1 = collectionDataFactory(org1);
|
||||
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
const orgKey2 = makeSymmetricCryptoKey<OrgKey>(64, 2);
|
||||
const collection2 = collectionDataFactory(org2);
|
||||
|
||||
// Arrange state provider
|
||||
const fakeStateProvider = mockStateProvider();
|
||||
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, {
|
||||
[collection1.id]: collection1,
|
||||
[collection2.id]: collection2,
|
||||
// Arrange dependencies
|
||||
await setEncryptedState([collection1, collection2]);
|
||||
cryptoKeys.next({
|
||||
[org1]: orgKey1,
|
||||
[org2]: orgKey2,
|
||||
});
|
||||
|
||||
// Arrange cryptoService - orgKeys and mock decryption
|
||||
const cryptoService = mockCryptoService();
|
||||
cryptoService.orgKeys$.mockReturnValue(
|
||||
of({
|
||||
[org1]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
[org2]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
}),
|
||||
);
|
||||
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
|
||||
|
||||
const collectionService = new DefaultCollectionService(
|
||||
cryptoService,
|
||||
mock<EncryptService>(),
|
||||
mockI18nService(),
|
||||
fakeStateProvider,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(collectionService.decryptedCollections$);
|
||||
// Assert emitted values
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: collection1.id,
|
||||
name: "DECRYPTED_STRING",
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
id: collection2.id,
|
||||
name: "DECRYPTED_STRING",
|
||||
});
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: "DEC_NAME_" + collection1.id,
|
||||
},
|
||||
{
|
||||
id: collection2.id,
|
||||
name: "DEC_NAME_" + collection2.id,
|
||||
},
|
||||
]);
|
||||
|
||||
// Assert that the correct org keys were used for each encrypted string
|
||||
// This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged
|
||||
expect(encryptService.decryptString).toHaveBeenCalledWith(
|
||||
expect.objectContaining(new EncString(collection1.name)),
|
||||
orgKey1,
|
||||
);
|
||||
expect(encryptService.decryptString).toHaveBeenCalledWith(
|
||||
expect.objectContaining(new EncString(collection2.name)),
|
||||
orgKey2,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits decrypted collections from in-memory state when available", async () => {
|
||||
// Arrange test data
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const collection1 = collectionViewDataFactory(org1);
|
||||
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
const collection2 = collectionViewDataFactory(org2);
|
||||
|
||||
await setDecryptedState([collection1, collection2]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
|
||||
|
||||
// Assert emitted values
|
||||
expect(result.length).toBe(2);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: "DEC_NAME_" + collection1.id,
|
||||
},
|
||||
{
|
||||
id: collection2.id,
|
||||
name: "DEC_NAME_" + collection2.id,
|
||||
},
|
||||
]);
|
||||
|
||||
// Ensure that the returned data came from the in-memory state, rather than from decryption.
|
||||
expect(encryptService.decryptString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles null collection state", async () => {
|
||||
// Arrange test collections
|
||||
// Arrange dependencies
|
||||
await setEncryptedState(null);
|
||||
cryptoKeys.next({});
|
||||
|
||||
const encryptedCollections = await firstValueFrom(
|
||||
collectionService.encryptedCollections$(userId),
|
||||
);
|
||||
|
||||
expect(encryptedCollections).toBe(null);
|
||||
});
|
||||
|
||||
it("handles undefined orgKeys", (done) => {
|
||||
// Arrange test data
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const collection1 = collectionDataFactory(org1);
|
||||
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
const collection2 = collectionDataFactory(org2);
|
||||
|
||||
// Arrange state provider
|
||||
const fakeStateProvider = mockStateProvider();
|
||||
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null);
|
||||
// Emit a non-null value after the first undefined value has propagated
|
||||
// This will cause the collections to emit, calling done()
|
||||
cryptoKeys.pipe(first()).subscribe((val) => {
|
||||
cryptoKeys.next({});
|
||||
});
|
||||
|
||||
// Arrange cryptoService - orgKeys and mock decryption
|
||||
const cryptoService = mockCryptoService();
|
||||
cryptoService.orgKeys$.mockReturnValue(
|
||||
of({
|
||||
[org1]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
[org2]: makeSymmetricCryptoKey<OrgKey>(),
|
||||
}),
|
||||
);
|
||||
collectionService
|
||||
.decryptedCollections$(userId)
|
||||
.pipe(takeWhile((val) => val.length != 2))
|
||||
.subscribe({ complete: () => done() });
|
||||
|
||||
const collectionService = new DefaultCollectionService(
|
||||
cryptoService,
|
||||
mock<EncryptService>(),
|
||||
mockI18nService(),
|
||||
fakeStateProvider,
|
||||
);
|
||||
// Arrange dependencies
|
||||
void setEncryptedState([collection1, collection2]).then(() => {
|
||||
// Act: emit undefined
|
||||
cryptoKeys.next(undefined);
|
||||
keyService.activeUserOrgKeys$ = of(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$);
|
||||
expect(decryptedCollections.length).toBe(0);
|
||||
it("Decrypts one time for multiple simultaneous callers", async () => {
|
||||
const decryptedMock: CollectionView[] = [{ id: "col1" }] as CollectionView[];
|
||||
const decryptManySpy = jest
|
||||
.spyOn(collectionService, "decryptMany$")
|
||||
.mockReturnValue(of(decryptedMock));
|
||||
|
||||
const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$);
|
||||
expect(encryptedCollections.length).toBe(0);
|
||||
jest
|
||||
.spyOn(collectionService as any, "encryptedCollections$")
|
||||
.mockReturnValue(of([{ id: "enc1" }]));
|
||||
jest.spyOn(keyService, "orgKeys$").mockReturnValue(of({ key: "fake-key" }));
|
||||
|
||||
// Simulate multiple subscribers
|
||||
const obs1 = collectionService.decryptedCollections$(userId);
|
||||
const obs2 = collectionService.decryptedCollections$(userId);
|
||||
const obs3 = collectionService.decryptedCollections$(userId);
|
||||
|
||||
await firstValueFrom(combineLatest([obs1, obs2, obs3]));
|
||||
|
||||
// Expect decryptMany$ to be called only once
|
||||
expect(decryptManySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptedCollections$", () => {
|
||||
it("emits encrypted collections from state", async () => {
|
||||
// Arrange test data
|
||||
const collection1 = collectionDataFactory();
|
||||
const collection2 = collectionDataFactory();
|
||||
|
||||
// Arrange dependencies
|
||||
await setEncryptedState([collection1, collection2]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
|
||||
expect(result!.length).toBe(2);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: makeEncString("ENC_NAME_" + collection1.id),
|
||||
},
|
||||
{
|
||||
id: collection2.id,
|
||||
name: makeEncString("ENC_NAME_" + collection2.id),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles null collection state", async () => {
|
||||
await setEncryptedState(null);
|
||||
|
||||
const decryptedCollections = await firstValueFrom(
|
||||
collectionService.encryptedCollections$(userId),
|
||||
);
|
||||
expect(decryptedCollections).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsert", () => {
|
||||
it("upserts to existing collections", async () => {
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
|
||||
const collection1 = collectionDataFactory(org1);
|
||||
|
||||
await setEncryptedState([collection1]);
|
||||
cryptoKeys.next({
|
||||
[collection1.organizationId]: orgKey1,
|
||||
});
|
||||
|
||||
const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, {
|
||||
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString,
|
||||
});
|
||||
|
||||
await collectionService.upsert(updatedCollection1, userId);
|
||||
|
||||
const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
|
||||
expect(encryptedResult!.length).toBe(1);
|
||||
expect(encryptedResult).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id),
|
||||
},
|
||||
]);
|
||||
|
||||
const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId));
|
||||
expect(decryptedResult.length).toBe(1);
|
||||
expect(decryptedResult).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: "UPDATED_DEC_NAME_" + collection1.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("upserts to a null state", async () => {
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
|
||||
const collection1 = collectionDataFactory(org1);
|
||||
|
||||
cryptoKeys.next({
|
||||
[collection1.organizationId]: orgKey1,
|
||||
});
|
||||
|
||||
await setEncryptedState(null);
|
||||
|
||||
await collectionService.upsert(collection1, userId);
|
||||
|
||||
const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(encryptedResult!.length).toBe(1);
|
||||
expect(encryptedResult).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: makeEncString("ENC_NAME_" + collection1.id),
|
||||
},
|
||||
]);
|
||||
|
||||
const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId));
|
||||
expect(decryptedResult.length).toBe(1);
|
||||
expect(decryptedResult).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: "DEC_NAME_" + collection1.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace", () => {
|
||||
it("replaces all collections", async () => {
|
||||
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
|
||||
|
||||
const newCollection3 = collectionDataFactory();
|
||||
await collectionService.replace(
|
||||
{
|
||||
[newCollection3.id]: newCollection3,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result!.length).toBe(1);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: newCollection3.id,
|
||||
name: makeEncString("ENC_NAME_" + newCollection3.id),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("deletes a collection", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
const collection2 = collectionDataFactory();
|
||||
await setEncryptedState([collection1, collection2]);
|
||||
|
||||
await collectionService.delete([collection1.id], userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result!.length).toEqual(1);
|
||||
expect(result![0]).toMatchObject({ id: collection2.id });
|
||||
});
|
||||
|
||||
it("deletes several collections", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
const collection2 = collectionDataFactory();
|
||||
const collection3 = collectionDataFactory();
|
||||
await setEncryptedState([collection1, collection2, collection3]);
|
||||
|
||||
await collectionService.delete([collection1.id, collection3.id], userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result!.length).toEqual(1);
|
||||
expect(result![0]).toMatchObject({ id: collection2.id });
|
||||
});
|
||||
|
||||
it("handles null collections", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
await setEncryptedState(null);
|
||||
|
||||
await collectionService.delete([collection1.id], userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result!.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
const setEncryptedState = (collectionData: CollectionData[] | null) =>
|
||||
stateProvider.setUserState(
|
||||
ENCRYPTED_COLLECTION_DATA_KEY,
|
||||
collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])),
|
||||
userId,
|
||||
);
|
||||
|
||||
const setDecryptedState = (collectionViews: CollectionView[] | null) =>
|
||||
stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collectionViews, userId);
|
||||
});
|
||||
|
||||
const mockI18nService = () => {
|
||||
const i18nService = mock<I18nService>();
|
||||
i18nService.collator = null; // this is a mock only, avoid use of this object
|
||||
return i18nService;
|
||||
};
|
||||
|
||||
const mockStateProvider = () => {
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
return new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
};
|
||||
|
||||
const mockCryptoService = () => {
|
||||
const keyService = mock<KeyService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
encryptService.decryptString
|
||||
.calledWith(expect.any(EncString), expect.anything())
|
||||
.mockResolvedValue("DECRYPTED_STRING");
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
|
||||
return keyService;
|
||||
};
|
||||
|
||||
const collectionDataFactory = (orgId: OrganizationId) => {
|
||||
const collectionDataFactory = (orgId?: OrganizationId) => {
|
||||
const collection = new CollectionData({} as any);
|
||||
collection.id = Utils.newGuid() as CollectionId;
|
||||
collection.organizationId = orgId;
|
||||
collection.name = makeEncString("ENC_STRING").encryptedString;
|
||||
collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
|
||||
collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString ?? "";
|
||||
|
||||
return collection;
|
||||
};
|
||||
|
||||
function collectionViewDataFactory(orgId?: OrganizationId): CollectionView {
|
||||
const collectionView = new CollectionView();
|
||||
collectionView.id = Utils.newGuid() as CollectionId;
|
||||
collectionView.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
|
||||
collectionView.name = "DEC_NAME_" + collectionView.id;
|
||||
return collectionView;
|
||||
}
|
||||
|
||||
@@ -1,113 +1,193 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
import {
|
||||
combineLatest,
|
||||
delayWhen,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
NEVER,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
ActiveUserState,
|
||||
COLLECTION_DATA,
|
||||
DeriveDefinition,
|
||||
DerivedState,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { CollectionService } from "../abstractions";
|
||||
import { CollectionService } from "../abstractions/collection.service";
|
||||
import { Collection, CollectionData, CollectionView } from "../models";
|
||||
|
||||
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
|
||||
COLLECTION_DATA,
|
||||
"collections",
|
||||
{
|
||||
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
|
||||
[Record<CollectionId, CollectionData>, Record<OrganizationId, OrgKey>],
|
||||
CollectionView[],
|
||||
{ collectionService: DefaultCollectionService }
|
||||
>(COLLECTION_DATA, "decryptedCollections", {
|
||||
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
|
||||
derive: async ([collections, orgKeys], { collectionService }) => {
|
||||
if (collections == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = Object.values(collections).map((c) => new Collection(c));
|
||||
return await collectionService.decryptMany(data, orgKeys);
|
||||
},
|
||||
});
|
||||
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
|
||||
|
||||
const NestingDelimiter = "/";
|
||||
|
||||
export class DefaultCollectionService implements CollectionService {
|
||||
private encryptedCollectionDataState: ActiveUserState<Record<CollectionId, CollectionData>>;
|
||||
encryptedCollections$: Observable<Collection[]>;
|
||||
private decryptedCollectionDataState: DerivedState<CollectionView[]>;
|
||||
decryptedCollections$: Observable<CollectionView[]>;
|
||||
|
||||
decryptedCollectionViews$(ids: CollectionId[]): Observable<CollectionView[]> {
|
||||
return this.decryptedCollections$.pipe(
|
||||
map((collections) => collections.filter((c) => ids.includes(c.id as CollectionId))),
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
private i18nService: I18nService,
|
||||
protected stateProvider: StateProvider,
|
||||
) {
|
||||
this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY);
|
||||
) {}
|
||||
|
||||
this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe(
|
||||
private collectionViewCache = new Map<UserId, Observable<CollectionView[]>>();
|
||||
|
||||
/**
|
||||
* @returns a SingleUserState for encrypted collection data.
|
||||
*/
|
||||
private encryptedState(
|
||||
userId: UserId,
|
||||
): SingleUserState<Record<CollectionId, CollectionData | null>> {
|
||||
return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a SingleUserState for decrypted collection data.
|
||||
*/
|
||||
private decryptedState(userId: UserId): SingleUserState<CollectionView[] | null> {
|
||||
return this.stateProvider.getUser(userId, DECRYPTED_COLLECTION_DATA_KEY);
|
||||
}
|
||||
|
||||
encryptedCollections$(userId: UserId): Observable<Collection[] | null> {
|
||||
return this.encryptedState(userId).state$.pipe(
|
||||
map((collections) => {
|
||||
if (collections == null) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.values(collections).map((c) => new Collection(c));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe(
|
||||
switchMap(([userId, collectionData]) =>
|
||||
combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]),
|
||||
decryptedCollections$(userId: UserId): Observable<CollectionView[]> {
|
||||
const cachedResult = this.collectionViewCache.get(userId);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const result$ = this.decryptedState(userId).state$.pipe(
|
||||
switchMap((decryptedState) => {
|
||||
// If decrypted state is already populated, return that
|
||||
if (decryptedState !== null) {
|
||||
return of(decryptedState ?? []);
|
||||
}
|
||||
|
||||
return this.initializeDecryptedState(userId).pipe(switchMap(() => NEVER));
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
this.collectionViewCache.set(userId, result$);
|
||||
return result$;
|
||||
}
|
||||
|
||||
private initializeDecryptedState(userId: UserId): Observable<CollectionView[]> {
|
||||
return combineLatest([
|
||||
this.encryptedCollections$(userId),
|
||||
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => !!orgKeys)),
|
||||
]).pipe(
|
||||
switchMap(([collections, orgKeys]) =>
|
||||
this.decryptMany$(collections, orgKeys).pipe(
|
||||
delayWhen((collections) => this.setDecryptedCollections(collections, userId)),
|
||||
),
|
||||
),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
this.decryptedCollectionDataState = this.stateProvider.getDerived(
|
||||
encryptedCollectionsWithKeys,
|
||||
DECRYPTED_COLLECTION_DATA_KEY,
|
||||
{ collectionService: this },
|
||||
);
|
||||
|
||||
this.decryptedCollections$ = this.decryptedCollectionDataState.state$;
|
||||
}
|
||||
|
||||
async clearActiveUserCache(): Promise<void> {
|
||||
await this.decryptedCollectionDataState.forceValue(null);
|
||||
async upsert(toUpdate: CollectionData, userId: UserId): Promise<void> {
|
||||
if (toUpdate == null) {
|
||||
return;
|
||||
}
|
||||
await this.encryptedState(userId).update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = {};
|
||||
}
|
||||
collections[toUpdate.id] = toUpdate;
|
||||
|
||||
return collections;
|
||||
});
|
||||
|
||||
const decryptedCollections = await firstValueFrom(
|
||||
this.keyService.orgKeys$(userId).pipe(
|
||||
switchMap((orgKeys) => {
|
||||
if (!orgKeys) {
|
||||
throw new Error("No key for this collection's organization.");
|
||||
}
|
||||
return this.decryptMany$([new Collection(toUpdate)], orgKeys);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.decryptedState(userId).update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = [];
|
||||
}
|
||||
|
||||
if (!decryptedCollections?.length) {
|
||||
return collections;
|
||||
}
|
||||
|
||||
const decryptedCollection = decryptedCollections[0];
|
||||
const existingIndex = collections.findIndex((collection) => collection.id == toUpdate.id);
|
||||
if (existingIndex >= 0) {
|
||||
collections[existingIndex] = decryptedCollection;
|
||||
} else {
|
||||
collections.push(decryptedCollection);
|
||||
}
|
||||
|
||||
return collections;
|
||||
});
|
||||
}
|
||||
|
||||
async encrypt(model: CollectionView): Promise<Collection> {
|
||||
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
|
||||
await this.encryptedState(userId).update(() => collections);
|
||||
await this.decryptedState(userId).update(() => null);
|
||||
}
|
||||
|
||||
async delete(ids: CollectionId[], userId: UserId): Promise<any> {
|
||||
await this.encryptedState(userId).update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = {};
|
||||
}
|
||||
ids.forEach((i) => {
|
||||
delete collections[i];
|
||||
});
|
||||
return collections;
|
||||
});
|
||||
|
||||
await this.decryptedState(userId).update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = [];
|
||||
}
|
||||
ids.forEach((i) => {
|
||||
if (collections?.length) {
|
||||
collections = collections.filter((c) => c.id != i) ?? [];
|
||||
}
|
||||
});
|
||||
return collections;
|
||||
});
|
||||
}
|
||||
|
||||
async encrypt(model: CollectionView, userId: UserId): Promise<Collection> {
|
||||
if (model.organizationId == null) {
|
||||
throw new Error("Collection has no organization id.");
|
||||
}
|
||||
const key = await this.keyService.getOrgKey(model.organizationId);
|
||||
if (key == null) {
|
||||
throw new Error("No key for this collection's organization.");
|
||||
}
|
||||
|
||||
const key = await firstValueFrom(
|
||||
this.keyService.orgKeys$(userId).pipe(
|
||||
filter((orgKeys) => !!orgKeys),
|
||||
map((k) => k[model.organizationId as OrganizationId]),
|
||||
),
|
||||
);
|
||||
|
||||
const collection = new Collection();
|
||||
collection.id = model.id;
|
||||
collection.organizationId = model.organizationId;
|
||||
@@ -117,58 +197,37 @@ export class DefaultCollectionService implements CollectionService {
|
||||
return collection;
|
||||
}
|
||||
|
||||
// TODO: this should be private and orgKeys should be required.
|
||||
// TODO: this should be private.
|
||||
// See https://bitwarden.atlassian.net/browse/PM-12375
|
||||
async decryptMany(
|
||||
collections: Collection[],
|
||||
orgKeys?: Record<OrganizationId, OrgKey>,
|
||||
): Promise<CollectionView[]> {
|
||||
if (collections == null || collections.length === 0) {
|
||||
return [];
|
||||
decryptMany$(
|
||||
collections: Collection[] | null,
|
||||
orgKeys: Record<OrganizationId, OrgKey>,
|
||||
): Observable<CollectionView[]> {
|
||||
if (collections === null || collections.length == 0 || orgKeys === null) {
|
||||
return of([]);
|
||||
}
|
||||
const decCollections: CollectionView[] = [];
|
||||
|
||||
orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$);
|
||||
const decCollections: Observable<CollectionView>[] = [];
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
collections.forEach((collection) => {
|
||||
promises.push(
|
||||
collection
|
||||
.decrypt(orgKeys[collection.organizationId as OrganizationId])
|
||||
.then((c) => decCollections.push(c)),
|
||||
decCollections.push(
|
||||
from(collection.decrypt(orgKeys[collection.organizationId as OrganizationId])),
|
||||
);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
return decCollections.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Collection> {
|
||||
return (
|
||||
(await firstValueFrom(
|
||||
this.encryptedCollections$.pipe(map((cs) => cs.find((c) => c.id === id))),
|
||||
)) ?? null
|
||||
return combineLatest(decCollections).pipe(
|
||||
map((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
|
||||
);
|
||||
}
|
||||
|
||||
async getAll(): Promise<Collection[]> {
|
||||
return await firstValueFrom(this.encryptedCollections$);
|
||||
}
|
||||
|
||||
async getAllDecrypted(): Promise<CollectionView[]> {
|
||||
return await firstValueFrom(this.decryptedCollections$);
|
||||
}
|
||||
|
||||
async getAllNested(collections: CollectionView[] = null): Promise<TreeNode<CollectionView>[]> {
|
||||
if (collections == null) {
|
||||
collections = await this.getAllDecrypted();
|
||||
}
|
||||
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
|
||||
const nodes: TreeNode<CollectionView>[] = [];
|
||||
collections.forEach((c) => {
|
||||
const collectionCopy = new CollectionView();
|
||||
collectionCopy.id = c.id;
|
||||
collectionCopy.organizationId = c.organizationId;
|
||||
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
@@ -177,58 +236,23 @@ export class DefaultCollectionService implements CollectionService {
|
||||
* @deprecated August 30 2022: Moved to new Vault Filter Service
|
||||
* Remove when Desktop and Browser are updated
|
||||
*/
|
||||
async getNested(id: string): Promise<TreeNode<CollectionView>> {
|
||||
const collections = await this.getAllNested();
|
||||
return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode<CollectionView>;
|
||||
getNested(collections: CollectionView[], id: string): TreeNode<CollectionView> {
|
||||
const nestedCollections = this.getAllNested(collections);
|
||||
return ServiceUtils.getTreeNodeObjectFromList(
|
||||
nestedCollections,
|
||||
id,
|
||||
) as TreeNode<CollectionView>;
|
||||
}
|
||||
|
||||
async upsert(toUpdate: CollectionData | CollectionData[]): Promise<void> {
|
||||
if (toUpdate == null) {
|
||||
return;
|
||||
}
|
||||
await this.encryptedCollectionDataState.update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = {};
|
||||
}
|
||||
if (Array.isArray(toUpdate)) {
|
||||
toUpdate.forEach((c) => {
|
||||
collections[c.id] = c;
|
||||
});
|
||||
} else {
|
||||
collections[toUpdate.id] = toUpdate;
|
||||
}
|
||||
return collections;
|
||||
});
|
||||
}
|
||||
|
||||
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
|
||||
await this.stateProvider
|
||||
.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY)
|
||||
.update(() => collections);
|
||||
}
|
||||
|
||||
async clear(userId?: UserId): Promise<void> {
|
||||
if (userId == null) {
|
||||
await this.encryptedCollectionDataState.update(() => null);
|
||||
await this.decryptedCollectionDataState.forceValue(null);
|
||||
} else {
|
||||
await this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY).update(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: CollectionId | CollectionId[]): Promise<any> {
|
||||
await this.encryptedCollectionDataState.update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = {};
|
||||
}
|
||||
if (typeof id === "string") {
|
||||
delete collections[id];
|
||||
} else {
|
||||
(id as CollectionId[]).forEach((i) => {
|
||||
delete collections[i];
|
||||
});
|
||||
}
|
||||
return collections;
|
||||
});
|
||||
/**
|
||||
* Sets the decrypted collections state for a user.
|
||||
* @param collections the decrypted collections
|
||||
* @param userId the user id
|
||||
*/
|
||||
private async setDecryptedCollections(
|
||||
collections: CollectionView[],
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await this.stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collections, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import {
|
||||
FakeStateProvider,
|
||||
makeEncString,
|
||||
makeSymmetricCryptoKey,
|
||||
mockAccountServiceWith,
|
||||
} from "@bitwarden/common/spec";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { CollectionData } from "../models";
|
||||
|
||||
import { DefaultvNextCollectionService } from "./default-vnext-collection.service";
|
||||
import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state";
|
||||
|
||||
describe("DefaultvNextCollectionService", () => {
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
let userId: UserId;
|
||||
|
||||
let cryptoKeys: ReplaySubject<Record<OrganizationId, OrgKey> | null>;
|
||||
|
||||
let collectionService: DefaultvNextCollectionService;
|
||||
|
||||
beforeEach(() => {
|
||||
userId = Utils.newGuid() as UserId;
|
||||
|
||||
keyService = mock();
|
||||
encryptService = mock();
|
||||
i18nService = mock();
|
||||
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
|
||||
cryptoKeys = new ReplaySubject(1);
|
||||
keyService.orgKeys$.mockReturnValue(cryptoKeys);
|
||||
|
||||
// Set up mock decryption
|
||||
encryptService.decryptString
|
||||
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey))
|
||||
.mockImplementation((encString, key) =>
|
||||
Promise.resolve(encString.data.replace("ENC_", "DEC_")),
|
||||
);
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
|
||||
// Arrange i18nService so that sorting algorithm doesn't throw
|
||||
i18nService.collator = null;
|
||||
|
||||
collectionService = new DefaultvNextCollectionService(
|
||||
keyService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).bitwardenContainerService;
|
||||
});
|
||||
|
||||
describe("decryptedCollections$", () => {
|
||||
it("emits decrypted collections from state", async () => {
|
||||
// Arrange test data
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
|
||||
const collection1 = collectionDataFactory(org1);
|
||||
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
const orgKey2 = makeSymmetricCryptoKey<OrgKey>(64, 2);
|
||||
const collection2 = collectionDataFactory(org2);
|
||||
|
||||
// Arrange dependencies
|
||||
await setEncryptedState([collection1, collection2]);
|
||||
cryptoKeys.next({
|
||||
[org1]: orgKey1,
|
||||
[org2]: orgKey2,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
|
||||
|
||||
// Assert emitted values
|
||||
expect(result.length).toBe(2);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: "DEC_NAME_" + collection1.id,
|
||||
},
|
||||
{
|
||||
id: collection2.id,
|
||||
name: "DEC_NAME_" + collection2.id,
|
||||
},
|
||||
]);
|
||||
|
||||
// Assert that the correct org keys were used for each encrypted string
|
||||
// This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged
|
||||
expect(encryptService.decryptString).toHaveBeenCalledWith(
|
||||
expect.objectContaining(new EncString(collection1.name)),
|
||||
orgKey1,
|
||||
);
|
||||
expect(encryptService.decryptString).toHaveBeenCalledWith(
|
||||
expect.objectContaining(new EncString(collection2.name)),
|
||||
orgKey2,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles null collection state", async () => {
|
||||
// Arrange dependencies
|
||||
await setEncryptedState(null);
|
||||
cryptoKeys.next({});
|
||||
|
||||
const encryptedCollections = await firstValueFrom(
|
||||
collectionService.encryptedCollections$(userId),
|
||||
);
|
||||
|
||||
expect(encryptedCollections.length).toBe(0);
|
||||
});
|
||||
|
||||
it("handles undefined orgKeys", (done) => {
|
||||
// Arrange test data
|
||||
const org1 = Utils.newGuid() as OrganizationId;
|
||||
const collection1 = collectionDataFactory(org1);
|
||||
|
||||
const org2 = Utils.newGuid() as OrganizationId;
|
||||
const collection2 = collectionDataFactory(org2);
|
||||
|
||||
// Emit a non-null value after the first undefined value has propagated
|
||||
// This will cause the collections to emit, calling done()
|
||||
cryptoKeys.pipe(first()).subscribe((val) => {
|
||||
cryptoKeys.next({});
|
||||
});
|
||||
|
||||
collectionService
|
||||
.decryptedCollections$(userId)
|
||||
.pipe(takeWhile((val) => val.length != 2))
|
||||
.subscribe({ complete: () => done() });
|
||||
|
||||
// Arrange dependencies
|
||||
void setEncryptedState([collection1, collection2]).then(() => {
|
||||
// Act: emit undefined
|
||||
cryptoKeys.next(undefined);
|
||||
keyService.activeUserOrgKeys$ = of(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptedCollections$", () => {
|
||||
it("emits encrypted collections from state", async () => {
|
||||
// Arrange test data
|
||||
const collection1 = collectionDataFactory();
|
||||
const collection2 = collectionDataFactory();
|
||||
|
||||
// Arrange dependencies
|
||||
await setEncryptedState([collection1, collection2]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: makeEncString("ENC_NAME_" + collection1.id),
|
||||
},
|
||||
{
|
||||
id: collection2.id,
|
||||
name: makeEncString("ENC_NAME_" + collection2.id),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles null collection state", async () => {
|
||||
await setEncryptedState(null);
|
||||
|
||||
const decryptedCollections = await firstValueFrom(
|
||||
collectionService.encryptedCollections$(userId),
|
||||
);
|
||||
expect(decryptedCollections.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsert", () => {
|
||||
it("upserts to existing collections", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
const collection2 = collectionDataFactory();
|
||||
|
||||
await setEncryptedState([collection1, collection2]);
|
||||
|
||||
const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, {
|
||||
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString,
|
||||
});
|
||||
const newCollection3 = collectionDataFactory();
|
||||
|
||||
await collectionService.upsert([updatedCollection1, newCollection3], userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result.length).toBe(3);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id),
|
||||
},
|
||||
{
|
||||
id: collection2.id,
|
||||
name: makeEncString("ENC_NAME_" + collection2.id),
|
||||
},
|
||||
{
|
||||
id: newCollection3.id,
|
||||
name: makeEncString("ENC_NAME_" + newCollection3.id),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("upserts to a null state", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
|
||||
await setEncryptedState(null);
|
||||
|
||||
await collectionService.upsert(collection1, userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result.length).toBe(1);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: collection1.id,
|
||||
name: makeEncString("ENC_NAME_" + collection1.id),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace", () => {
|
||||
it("replaces all collections", async () => {
|
||||
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
|
||||
|
||||
const newCollection3 = collectionDataFactory();
|
||||
await collectionService.replace(
|
||||
{
|
||||
[newCollection3.id]: newCollection3,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result.length).toBe(1);
|
||||
expect(result).toContainPartialObjects([
|
||||
{
|
||||
id: newCollection3.id,
|
||||
name: makeEncString("ENC_NAME_" + newCollection3.id),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("clearDecryptedState", async () => {
|
||||
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
|
||||
|
||||
await collectionService.clearDecryptedState(userId);
|
||||
|
||||
// Encrypted state remains
|
||||
const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(encryptedState.length).toEqual(2);
|
||||
|
||||
// Decrypted state is cleared
|
||||
const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId));
|
||||
expect(decryptedState.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("clear", async () => {
|
||||
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
|
||||
cryptoKeys.next({});
|
||||
|
||||
await collectionService.clear(userId);
|
||||
|
||||
// Encrypted state is cleared
|
||||
const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(encryptedState.length).toEqual(0);
|
||||
|
||||
// Decrypted state is cleared
|
||||
const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId));
|
||||
expect(decryptedState.length).toEqual(0);
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("deletes a collection", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
const collection2 = collectionDataFactory();
|
||||
await setEncryptedState([collection1, collection2]);
|
||||
|
||||
await collectionService.delete(collection1.id, userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result.length).toEqual(1);
|
||||
expect(result[0]).toMatchObject({ id: collection2.id });
|
||||
});
|
||||
|
||||
it("deletes several collections", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
const collection2 = collectionDataFactory();
|
||||
const collection3 = collectionDataFactory();
|
||||
await setEncryptedState([collection1, collection2, collection3]);
|
||||
|
||||
await collectionService.delete([collection1.id, collection3.id], userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result.length).toEqual(1);
|
||||
expect(result[0]).toMatchObject({ id: collection2.id });
|
||||
});
|
||||
|
||||
it("handles null collections", async () => {
|
||||
const collection1 = collectionDataFactory();
|
||||
await setEncryptedState(null);
|
||||
|
||||
await collectionService.delete(collection1.id, userId);
|
||||
|
||||
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||
expect(result.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
const setEncryptedState = (collectionData: CollectionData[] | null) =>
|
||||
stateProvider.setUserState(
|
||||
ENCRYPTED_COLLECTION_DATA_KEY,
|
||||
collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])),
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
const collectionDataFactory = (orgId?: OrganizationId) => {
|
||||
const collection = new CollectionData({} as any);
|
||||
collection.id = Utils.newGuid() as CollectionId;
|
||||
collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
|
||||
collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString;
|
||||
|
||||
return collection;
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, filter, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { StateProvider, DerivedState } from "@bitwarden/common/platform/state";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { vNextCollectionService } from "../abstractions/vnext-collection.service";
|
||||
import { Collection, CollectionData, CollectionView } from "../models";
|
||||
|
||||
import {
|
||||
DECRYPTED_COLLECTION_DATA_KEY,
|
||||
ENCRYPTED_COLLECTION_DATA_KEY,
|
||||
} from "./vnext-collection.state";
|
||||
|
||||
const NestingDelimiter = "/";
|
||||
|
||||
export class DefaultvNextCollectionService implements vNextCollectionService {
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
private i18nService: I18nService,
|
||||
protected stateProvider: StateProvider,
|
||||
) {}
|
||||
|
||||
encryptedCollections$(userId: UserId) {
|
||||
return this.encryptedState(userId).state$.pipe(
|
||||
map((collections) => {
|
||||
if (collections == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(collections).map((c) => new Collection(c));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
decryptedCollections$(userId: UserId) {
|
||||
return this.decryptedState(userId).state$.pipe(map((collections) => collections ?? []));
|
||||
}
|
||||
|
||||
async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise<void> {
|
||||
if (toUpdate == null) {
|
||||
return;
|
||||
}
|
||||
await this.encryptedState(userId).update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = {};
|
||||
}
|
||||
if (Array.isArray(toUpdate)) {
|
||||
toUpdate.forEach((c) => {
|
||||
collections[c.id] = c;
|
||||
});
|
||||
} else {
|
||||
collections[toUpdate.id] = toUpdate;
|
||||
}
|
||||
return collections;
|
||||
});
|
||||
}
|
||||
|
||||
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
|
||||
await this.encryptedState(userId).update(() => collections);
|
||||
}
|
||||
|
||||
async clearDecryptedState(userId: UserId): Promise<void> {
|
||||
if (userId == null) {
|
||||
throw new Error("User ID is required.");
|
||||
}
|
||||
|
||||
await this.decryptedState(userId).forceValue([]);
|
||||
}
|
||||
|
||||
async clear(userId: UserId): Promise<void> {
|
||||
await this.encryptedState(userId).update(() => null);
|
||||
// This will propagate from the encrypted state update, but by doing it explicitly
|
||||
// the promise doesn't resolve until the update is complete.
|
||||
await this.decryptedState(userId).forceValue([]);
|
||||
}
|
||||
|
||||
async delete(id: CollectionId | CollectionId[], userId: UserId): Promise<any> {
|
||||
await this.encryptedState(userId).update((collections) => {
|
||||
if (collections == null) {
|
||||
collections = {};
|
||||
}
|
||||
if (typeof id === "string") {
|
||||
delete collections[id];
|
||||
} else {
|
||||
(id as CollectionId[]).forEach((i) => {
|
||||
delete collections[i];
|
||||
});
|
||||
}
|
||||
return collections;
|
||||
});
|
||||
}
|
||||
|
||||
async encrypt(model: CollectionView): Promise<Collection> {
|
||||
if (model.organizationId == null) {
|
||||
throw new Error("Collection has no organization id.");
|
||||
}
|
||||
const key = await this.keyService.getOrgKey(model.organizationId);
|
||||
if (key == null) {
|
||||
throw new Error("No key for this collection's organization.");
|
||||
}
|
||||
const collection = new Collection();
|
||||
collection.id = model.id;
|
||||
collection.organizationId = model.organizationId;
|
||||
collection.readOnly = model.readOnly;
|
||||
collection.externalId = model.externalId;
|
||||
collection.name = await this.encryptService.encryptString(model.name, key);
|
||||
return collection;
|
||||
}
|
||||
|
||||
// TODO: this should be private and orgKeys should be required.
|
||||
// See https://bitwarden.atlassian.net/browse/PM-12375
|
||||
async decryptMany(
|
||||
collections: Collection[],
|
||||
orgKeys?: Record<OrganizationId, OrgKey> | null,
|
||||
): Promise<CollectionView[]> {
|
||||
if (collections == null || collections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const decCollections: CollectionView[] = [];
|
||||
|
||||
orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$);
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
collections.forEach((collection) => {
|
||||
promises.push(
|
||||
collection
|
||||
.decrypt(orgKeys[collection.organizationId as OrganizationId])
|
||||
.then((c) => decCollections.push(c)),
|
||||
);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
return decCollections.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
}
|
||||
|
||||
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
|
||||
const nodes: TreeNode<CollectionView>[] = [];
|
||||
collections.forEach((c) => {
|
||||
const collectionCopy = new CollectionView();
|
||||
collectionCopy.id = c.id;
|
||||
collectionCopy.organizationId = c.organizationId;
|
||||
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated August 30 2022: Moved to new Vault Filter Service
|
||||
* Remove when Desktop and Browser are updated
|
||||
*/
|
||||
getNested(collections: CollectionView[], id: string): TreeNode<CollectionView> {
|
||||
const nestedCollections = this.getAllNested(collections);
|
||||
return ServiceUtils.getTreeNodeObjectFromList(
|
||||
nestedCollections,
|
||||
id,
|
||||
) as TreeNode<CollectionView>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a SingleUserState for encrypted collection data.
|
||||
*/
|
||||
private encryptedState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a SingleUserState for decrypted collection data.
|
||||
*/
|
||||
private decryptedState(userId: UserId): DerivedState<CollectionView[]> {
|
||||
const encryptedCollectionsWithKeys$ = combineLatest([
|
||||
this.encryptedCollections$(userId),
|
||||
// orgKeys$ can emit null during brief moments on unlock and lock/logout, we want to ignore those intermediate states
|
||||
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)),
|
||||
]);
|
||||
|
||||
return this.stateProvider.getDerived(
|
||||
encryptedCollectionsWithKeys$,
|
||||
DECRYPTED_COLLECTION_DATA_KEY,
|
||||
{
|
||||
collectionService: this,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
COLLECTION_DATA,
|
||||
DeriveDefinition,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { vNextCollectionService } from "../abstractions/vnext-collection.service";
|
||||
import { Collection, CollectionData, CollectionView } from "../models";
|
||||
|
||||
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
|
||||
COLLECTION_DATA,
|
||||
"collections",
|
||||
{
|
||||
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
|
||||
[Collection[], Record<OrganizationId, OrgKey> | null],
|
||||
CollectionView[],
|
||||
{ collectionService: vNextCollectionService }
|
||||
>(COLLECTION_DATA, "decryptedCollections", {
|
||||
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
|
||||
derive: async ([collections, orgKeys], { collectionService }) => {
|
||||
if (collections == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await collectionService.decryptMany(collections, orgKeys);
|
||||
},
|
||||
});
|
||||
@@ -37,13 +37,6 @@ export abstract class OrganizationUserApiService {
|
||||
},
|
||||
): Promise<OrganizationUserDetailsResponse>;
|
||||
|
||||
/**
|
||||
* Retrieve a list of groups Ids the specified organization user belongs to
|
||||
* @param organizationId - Identifier for the user's organization
|
||||
* @param id - Organization user identifier
|
||||
*/
|
||||
abstract getOrganizationUserGroups(organizationId: string, id: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Retrieve full details of all users that belong to the specified organization.
|
||||
* This is only accessible to privileged users, if you need a simple listing of basic details, use
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
|
||||
export class OrganizationUserConfirmRequest {
|
||||
key: EncryptedString | undefined;
|
||||
|
||||
@@ -48,17 +48,6 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
|
||||
return new OrganizationUserDetailsResponse(r);
|
||||
}
|
||||
|
||||
async getOrganizationUserGroups(organizationId: string, id: string): Promise<string[]> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/users/" + id + "/groups",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return r;
|
||||
}
|
||||
|
||||
async getAllUsers(
|
||||
organizationId: string,
|
||||
options?: {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
@Directive()
|
||||
export class CollectionsComponent implements OnInit {
|
||||
@Input() cipherId: string;
|
||||
@Input() allowSelectNone = false;
|
||||
@Output() onSavedCollections = new EventEmitter();
|
||||
|
||||
formPromise: Promise<any>;
|
||||
cipher: CipherView;
|
||||
collectionIds: string[];
|
||||
collections: CollectionView[] = [];
|
||||
organization: Organization;
|
||||
|
||||
protected cipherDomain: Cipher;
|
||||
|
||||
constructor(
|
||||
protected collectionService: CollectionService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected i18nService: I18nService,
|
||||
protected cipherService: CipherService,
|
||||
protected organizationService: OrganizationService,
|
||||
private logService: LogService,
|
||||
private accountService: AccountService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.cipherDomain = await this.loadCipher(activeUserId);
|
||||
this.collectionIds = this.loadCipherCollections();
|
||||
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
|
||||
this.collections = await this.loadCollections();
|
||||
|
||||
this.collections.forEach((c) => ((c as any).checked = false));
|
||||
if (this.collectionIds != null) {
|
||||
this.collections.forEach((c) => {
|
||||
(c as any).checked = this.collectionIds != null && this.collectionIds.indexOf(c.id) > -1;
|
||||
});
|
||||
}
|
||||
|
||||
if (this.organization == null) {
|
||||
this.organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(activeUserId)
|
||||
.pipe(
|
||||
map((organizations) =>
|
||||
organizations.find((org) => org.id === this.cipher.organizationId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async submit(): Promise<boolean> {
|
||||
const selectedCollectionIds = this.collections
|
||||
.filter((c) => {
|
||||
if (this.organization.canEditAllCiphers) {
|
||||
return !!(c as any).checked;
|
||||
} else {
|
||||
return !!(c as any).checked && !c.readOnly;
|
||||
}
|
||||
})
|
||||
.map((c) => c.id);
|
||||
if (!this.allowSelectNone && selectedCollectionIds.length === 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("selectOneCollection"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this.cipherDomain.collectionIds = selectedCollectionIds;
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.formPromise = this.saveCollections(activeUserId);
|
||||
await this.formPromise;
|
||||
this.onSavedCollections.emit();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("editedItem"),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected loadCipher(userId: UserId) {
|
||||
return this.cipherService.get(this.cipherId, userId);
|
||||
}
|
||||
|
||||
protected loadCipherCollections() {
|
||||
return this.cipherDomain.collectionIds;
|
||||
}
|
||||
|
||||
protected async loadCollections() {
|
||||
const allCollections = await this.collectionService.getAllDecrypted();
|
||||
return allCollections.filter(
|
||||
(c) => !c.readOnly && c.organizationId === this.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
protected saveCollections(userId: UserId) {
|
||||
return this.cipherService.saveCollectionsWithServer(this.cipherDomain, userId);
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { PasswordColorText } from "../../tools/password-strength/password-strength.component";
|
||||
|
||||
@Directive()
|
||||
export class ChangePasswordComponent implements OnInit, OnDestroy {
|
||||
masterPassword: string;
|
||||
masterPasswordRetype: string;
|
||||
formPromise: Promise<any>;
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
passwordStrengthResult: any;
|
||||
color: string;
|
||||
text: string;
|
||||
leakedPassword: boolean;
|
||||
minimumLength = Utils.minimumPasswordLength;
|
||||
|
||||
protected email: string;
|
||||
protected kdfConfig: KdfConfig;
|
||||
|
||||
protected destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
protected keyService: KeyService,
|
||||
protected messagingService: MessagingService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected policyService: PolicyService,
|
||||
protected dialogService: DialogService,
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe(
|
||||
(enforcedPasswordPolicyOptions) =>
|
||||
(this.enforcedPolicyOptions ??= enforcedPasswordPolicyOptions),
|
||||
);
|
||||
|
||||
if (this.enforcedPolicyOptions?.minLength) {
|
||||
this.minimumLength = this.enforcedPolicyOptions.minLength;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!(await this.strongPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.setupSubmitActions())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [userId, email] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
|
||||
);
|
||||
|
||||
if (this.kdfConfig == null) {
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
}
|
||||
|
||||
// Create new master key
|
||||
const newMasterKey = await this.keyService.makeMasterKey(
|
||||
this.masterPassword,
|
||||
email.trim().toLowerCase(),
|
||||
this.kdfConfig,
|
||||
);
|
||||
const newMasterKeyHash = await this.keyService.hashMasterKey(this.masterPassword, newMasterKey);
|
||||
|
||||
let newProtectedUserKey: [UserKey, EncString] = null;
|
||||
const userKey = await this.keyService.getUserKey();
|
||||
if (userKey == null) {
|
||||
newProtectedUserKey = await this.keyService.makeUserKey(newMasterKey);
|
||||
} else {
|
||||
newProtectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey);
|
||||
}
|
||||
|
||||
await this.performSubmitActions(newMasterKeyHash, newMasterKey, newProtectedUserKey);
|
||||
}
|
||||
|
||||
async setupSubmitActions(): Promise<boolean> {
|
||||
// Override in sub-class
|
||||
// Can be used for additional validation and/or other processes the should occur before changing passwords
|
||||
return true;
|
||||
}
|
||||
|
||||
async performSubmitActions(
|
||||
newMasterKeyHash: string,
|
||||
newMasterKey: MasterKey,
|
||||
newUserKey: [UserKey, EncString],
|
||||
) {
|
||||
// Override in sub-class
|
||||
}
|
||||
|
||||
async strongPassword(): Promise<boolean> {
|
||||
if (this.masterPassword == null || this.masterPassword === "") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPasswordRequired"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (this.masterPassword.length < this.minimumLength) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPasswordMinimumlength", this.minimumLength),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (this.masterPassword !== this.masterPasswordRetype) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPassDoesntMatch"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const strengthResult = this.passwordStrengthResult;
|
||||
|
||||
if (
|
||||
this.enforcedPolicyOptions != null &&
|
||||
!this.policyService.evaluateMasterPassword(
|
||||
strengthResult.score,
|
||||
this.masterPassword,
|
||||
this.enforcedPolicyOptions,
|
||||
)
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const weakPassword = strengthResult != null && strengthResult.score < 3;
|
||||
|
||||
if (weakPassword && this.leakedPassword) {
|
||||
const result = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "weakAndExposedMasterPassword" },
|
||||
content: { key: "weakAndBreachedMasterPasswordDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (weakPassword) {
|
||||
const result = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "weakMasterPassword" },
|
||||
content: { key: "weakMasterPasswordDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this.leakedPassword) {
|
||||
const result = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "exposedMasterPassword" },
|
||||
content: { key: "exposedMasterPasswordDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async logOut() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "logOut" },
|
||||
content: { key: "logOutConfirmation" },
|
||||
acceptButtonText: { key: "logOut" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
}
|
||||
|
||||
getStrengthResult(result: any) {
|
||||
this.passwordStrengthResult = result;
|
||||
}
|
||||
|
||||
getPasswordScoreText(event: PasswordColorText) {
|
||||
this.color = event.color;
|
||||
this.text = event.text;
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { filter, first, switchMap, tap } from "rxjs/operators";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordEnrollmentRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { OrganizationAutoEnrollStatusResponse } from "@bitwarden/common/admin-console/models/response/organization-auto-enroll-status.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
|
||||
|
||||
@Directive()
|
||||
export class SetPasswordComponent extends BaseChangePasswordComponent implements OnInit {
|
||||
syncLoading = true;
|
||||
showPassword = false;
|
||||
hint = "";
|
||||
orgSsoIdentifier: string = null;
|
||||
orgId: string;
|
||||
resetPasswordAutoEnroll = false;
|
||||
onSuccessfulChangePassword: () => Promise<void>;
|
||||
successRoute = "vault";
|
||||
activeUserId: UserId;
|
||||
|
||||
forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
|
||||
ForceSetPasswordReason = ForceSetPasswordReason;
|
||||
|
||||
constructor(
|
||||
accountService: AccountService,
|
||||
masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
i18nService: I18nService,
|
||||
keyService: KeyService,
|
||||
messagingService: MessagingService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
policyService: PolicyService,
|
||||
protected router: Router,
|
||||
private masterPasswordApiService: MasterPasswordApiService,
|
||||
private apiService: ApiService,
|
||||
private syncService: SyncService,
|
||||
private route: ActivatedRoute,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
dialogService: DialogService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
private encryptService: EncryptService,
|
||||
protected toastService: ToastService,
|
||||
) {
|
||||
super(
|
||||
i18nService,
|
||||
keyService,
|
||||
messagingService,
|
||||
platformUtilsService,
|
||||
policyService,
|
||||
dialogService,
|
||||
kdfConfigService,
|
||||
masterPasswordService,
|
||||
accountService,
|
||||
toastService,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// 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
|
||||
super.ngOnInit();
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
this.syncLoading = false;
|
||||
|
||||
this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
this.forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(this.activeUserId),
|
||||
);
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(
|
||||
first(),
|
||||
switchMap((qParams) => {
|
||||
if (qParams.identifier != null) {
|
||||
return of(qParams.identifier);
|
||||
} else {
|
||||
// Try to get orgSsoId from state as fallback
|
||||
// Note: this is primarily for the TDE user w/out MP obtains admin MP reset permission scenario.
|
||||
return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeUserId);
|
||||
}
|
||||
}),
|
||||
filter((orgSsoId) => orgSsoId != null),
|
||||
tap((orgSsoId: string) => {
|
||||
this.orgSsoIdentifier = orgSsoId;
|
||||
}),
|
||||
switchMap((orgSsoId: string) => this.organizationApiService.getAutoEnrollStatus(orgSsoId)),
|
||||
tap((orgAutoEnrollStatusResponse: OrganizationAutoEnrollStatusResponse) => {
|
||||
this.orgId = orgAutoEnrollStatusResponse.id;
|
||||
this.resetPasswordAutoEnroll = orgAutoEnrollStatusResponse.resetPasswordEnabled;
|
||||
}),
|
||||
switchMap((orgAutoEnrollStatusResponse: OrganizationAutoEnrollStatusResponse) =>
|
||||
// Must get org id from response to get master password policy options
|
||||
this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(
|
||||
orgAutoEnrollStatusResponse.id,
|
||||
),
|
||||
),
|
||||
tap((masterPasswordPolicyOptions: MasterPasswordPolicyOptions) => {
|
||||
this.enforcedPolicyOptions = masterPasswordPolicyOptions;
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
error: () => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setupSubmitActions() {
|
||||
this.kdfConfig = DEFAULT_KDF_CONFIG;
|
||||
return true;
|
||||
}
|
||||
|
||||
async performSubmitActions(
|
||||
masterPasswordHash: string,
|
||||
masterKey: MasterKey,
|
||||
userKey: [UserKey, EncString],
|
||||
) {
|
||||
let keysRequest: KeysRequest | null = null;
|
||||
let newKeyPair: [string, EncString] | null = null;
|
||||
|
||||
if (
|
||||
this.forceSetPasswordReason !=
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
|
||||
) {
|
||||
// Existing JIT provisioned user in a MP encryption org setting first password
|
||||
// Users in this state will not already have a user asymmetric key pair so must create it for them
|
||||
// We don't want to re-create the user key pair if the user already has one (TDE user case)
|
||||
|
||||
// in case we have a local private key, and are not sure whether it has been posted to the server, we post the local private key instead of generating a new one
|
||||
const existingUserPrivateKey = (await firstValueFrom(
|
||||
this.keyService.userPrivateKey$(this.activeUserId),
|
||||
)) as Uint8Array;
|
||||
const existingUserPublicKey = await firstValueFrom(
|
||||
this.keyService.userPublicKey$(this.activeUserId),
|
||||
);
|
||||
if (existingUserPrivateKey != null && existingUserPublicKey != null) {
|
||||
const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey);
|
||||
newKeyPair = [
|
||||
existingUserPublicKeyB64,
|
||||
await this.encryptService.wrapDecapsulationKey(existingUserPrivateKey, userKey[0]),
|
||||
];
|
||||
} else {
|
||||
newKeyPair = await this.keyService.makeKeyPair(userKey[0]);
|
||||
}
|
||||
keysRequest = new KeysRequest(newKeyPair[0], newKeyPair[1].encryptedString);
|
||||
}
|
||||
|
||||
const request = new SetPasswordRequest(
|
||||
masterPasswordHash,
|
||||
userKey[1].encryptedString,
|
||||
this.hint,
|
||||
this.orgSsoIdentifier,
|
||||
keysRequest,
|
||||
this.kdfConfig.kdfType, //always PBKDF2 --> see this.setupSubmitActions
|
||||
this.kdfConfig.iterations,
|
||||
);
|
||||
try {
|
||||
if (this.resetPasswordAutoEnroll) {
|
||||
this.formPromise = this.masterPasswordApiService
|
||||
.setPassword(request)
|
||||
.then(async () => {
|
||||
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
|
||||
return this.organizationApiService.getKeys(this.orgId);
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response == null) {
|
||||
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
|
||||
}
|
||||
const publicKey = Utils.fromB64ToArray(response.publicKey);
|
||||
|
||||
// RSA Encrypt user key with organization public key
|
||||
const userKey = await this.keyService.getUserKey();
|
||||
const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
userKey,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
resetRequest.masterPasswordHash = masterPasswordHash;
|
||||
resetRequest.resetPasswordKey = encryptedUserKey.encryptedString;
|
||||
|
||||
return this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
|
||||
this.orgId,
|
||||
this.activeUserId,
|
||||
resetRequest,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.formPromise = this.masterPasswordApiService.setPassword(request).then(async () => {
|
||||
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
|
||||
});
|
||||
}
|
||||
|
||||
await this.formPromise;
|
||||
|
||||
if (this.onSuccessfulChangePassword != null) {
|
||||
// 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.onSuccessfulChangePassword();
|
||||
} else {
|
||||
// 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([this.successRoute]);
|
||||
}
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
togglePassword(confirmField: boolean) {
|
||||
this.showPassword = !this.showPassword;
|
||||
document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus();
|
||||
}
|
||||
|
||||
protected async onSetPasswordSuccess(
|
||||
masterKey: MasterKey,
|
||||
userKey: [UserKey, EncString],
|
||||
keyPair: [string, EncString] | null,
|
||||
) {
|
||||
// Clear force set password reason to allow navigation back to vault.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.None,
|
||||
this.activeUserId,
|
||||
);
|
||||
|
||||
// User now has a password so update account decryption options in state
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
userDecryptionOpts.hasMasterPassword = true;
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
|
||||
await this.kdfConfigService.setKdfConfig(this.activeUserId, this.kdfConfig);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, this.activeUserId);
|
||||
await this.keyService.setUserKey(userKey[0], this.activeUserId);
|
||||
|
||||
// Set private key only for new JIT provisioned users in MP encryption orgs
|
||||
// Existing TDE users will have private key set on sync or on login
|
||||
if (
|
||||
keyPair !== null &&
|
||||
this.forceSetPasswordReason !=
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
|
||||
) {
|
||||
await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.activeUserId);
|
||||
}
|
||||
|
||||
const localMasterKeyHash = await this.keyService.hashMasterKey(
|
||||
this.masterPassword,
|
||||
masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.activeUserId);
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
|
||||
|
||||
@Directive()
|
||||
export class UpdatePasswordComponent extends BaseChangePasswordComponent {
|
||||
hint: string;
|
||||
key: string;
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
showPassword = false;
|
||||
currentMasterPassword: string;
|
||||
|
||||
onSuccessfulChangePassword: () => Promise<void>;
|
||||
|
||||
constructor(
|
||||
protected router: Router,
|
||||
i18nService: I18nService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
policyService: PolicyService,
|
||||
keyService: KeyService,
|
||||
messagingService: MessagingService,
|
||||
private masterPasswordApiService: MasterPasswordApiService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private logService: LogService,
|
||||
dialogService: DialogService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
accountService: AccountService,
|
||||
toastService: ToastService,
|
||||
) {
|
||||
super(
|
||||
i18nService,
|
||||
keyService,
|
||||
messagingService,
|
||||
platformUtilsService,
|
||||
policyService,
|
||||
dialogService,
|
||||
kdfConfigService,
|
||||
masterPasswordService,
|
||||
accountService,
|
||||
toastService,
|
||||
);
|
||||
}
|
||||
|
||||
togglePassword(confirmField: boolean) {
|
||||
this.showPassword = !this.showPassword;
|
||||
document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus();
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
await this.router.navigate(["/vault"]);
|
||||
}
|
||||
|
||||
async setupSubmitActions(): Promise<boolean> {
|
||||
if (this.currentMasterPassword == null || this.currentMasterPassword === "") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPasswordRequired"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const secret: Verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: this.currentMasterPassword,
|
||||
};
|
||||
try {
|
||||
await this.userVerificationService.verifyUser(secret);
|
||||
} catch (e) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async performSubmitActions(
|
||||
newMasterKeyHash: string,
|
||||
newMasterKey: MasterKey,
|
||||
newUserKey: [UserKey, EncString],
|
||||
) {
|
||||
try {
|
||||
// Create Request
|
||||
const request = new PasswordRequest();
|
||||
request.masterPasswordHash = await this.keyService.hashMasterKey(
|
||||
this.currentMasterPassword,
|
||||
await this.keyService.getOrDeriveMasterKey(this.currentMasterPassword),
|
||||
);
|
||||
request.newMasterPasswordHash = newMasterKeyHash;
|
||||
request.key = newUserKey[1].encryptedString;
|
||||
|
||||
// Update user's password
|
||||
await this.masterPasswordApiService.postPassword(request);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("masterPasswordChanged"),
|
||||
message: this.i18nService.t("logBackIn"),
|
||||
});
|
||||
|
||||
if (this.onSuccessfulChangePassword != null) {
|
||||
// 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.onSuccessfulChangePassword();
|
||||
} else {
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
|
||||
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
|
||||
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
|
||||
|
||||
@Directive()
|
||||
export class UpdateTempPasswordComponent extends BaseChangePasswordComponent implements OnInit {
|
||||
hint: string;
|
||||
key: string;
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
showPassword = false;
|
||||
reason: ForceSetPasswordReason = ForceSetPasswordReason.None;
|
||||
verification: MasterPasswordVerification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "",
|
||||
};
|
||||
|
||||
onSuccessfulChangePassword: () => Promise<any>;
|
||||
|
||||
get requireCurrentPassword(): boolean {
|
||||
return this.reason === ForceSetPasswordReason.WeakMasterPassword;
|
||||
}
|
||||
|
||||
constructor(
|
||||
i18nService: I18nService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
policyService: PolicyService,
|
||||
keyService: KeyService,
|
||||
messagingService: MessagingService,
|
||||
private masterPasswordApiService: MasterPasswordApiService,
|
||||
private syncService: SyncService,
|
||||
private logService: LogService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
protected router: Router,
|
||||
dialogService: DialogService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
accountService: AccountService,
|
||||
masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
toastService: ToastService,
|
||||
) {
|
||||
super(
|
||||
i18nService,
|
||||
keyService,
|
||||
messagingService,
|
||||
platformUtilsService,
|
||||
policyService,
|
||||
dialogService,
|
||||
kdfConfigService,
|
||||
masterPasswordService,
|
||||
accountService,
|
||||
toastService,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
this.reason = await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId));
|
||||
|
||||
// If we somehow end up here without a reason, go back to the home page
|
||||
if (this.reason == ForceSetPasswordReason.None) {
|
||||
// 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(["/"]);
|
||||
return;
|
||||
}
|
||||
|
||||
await super.ngOnInit();
|
||||
}
|
||||
|
||||
get masterPasswordWarningText(): string {
|
||||
if (this.reason == ForceSetPasswordReason.WeakMasterPassword) {
|
||||
return this.i18nService.t("updateWeakMasterPasswordWarning");
|
||||
} else if (this.reason == ForceSetPasswordReason.TdeOffboarding) {
|
||||
return this.i18nService.t("tdeDisabledMasterPasswordRequired");
|
||||
} else {
|
||||
return this.i18nService.t("updateMasterPasswordWarning");
|
||||
}
|
||||
}
|
||||
|
||||
togglePassword(confirmField: boolean) {
|
||||
this.showPassword = !this.showPassword;
|
||||
document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus();
|
||||
}
|
||||
|
||||
async setupSubmitActions(): Promise<boolean> {
|
||||
const [userId, email] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
|
||||
);
|
||||
this.email = email;
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
// Validation
|
||||
if (!(await this.strongPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.setupSubmitActions())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create new key and hash new password
|
||||
const newMasterKey = await this.keyService.makeMasterKey(
|
||||
this.masterPassword,
|
||||
this.email.trim().toLowerCase(),
|
||||
this.kdfConfig,
|
||||
);
|
||||
const newPasswordHash = await this.keyService.hashMasterKey(
|
||||
this.masterPassword,
|
||||
newMasterKey,
|
||||
);
|
||||
|
||||
// Grab user key
|
||||
const userKey = await this.keyService.getUserKey();
|
||||
|
||||
// Encrypt user key with new master key
|
||||
const newProtectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
|
||||
newMasterKey,
|
||||
userKey,
|
||||
);
|
||||
|
||||
await this.performSubmitActions(newPasswordHash, newMasterKey, newProtectedUserKey);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async performSubmitActions(
|
||||
masterPasswordHash: string,
|
||||
masterKey: MasterKey,
|
||||
userKey: [UserKey, EncString],
|
||||
) {
|
||||
try {
|
||||
switch (this.reason) {
|
||||
case ForceSetPasswordReason.AdminForcePasswordReset:
|
||||
this.formPromise = this.updateTempPassword(masterPasswordHash, userKey);
|
||||
break;
|
||||
case ForceSetPasswordReason.WeakMasterPassword:
|
||||
this.formPromise = this.updatePassword(masterPasswordHash, userKey);
|
||||
break;
|
||||
case ForceSetPasswordReason.TdeOffboarding:
|
||||
this.formPromise = this.updateTdeOffboardingPassword(masterPasswordHash, userKey);
|
||||
break;
|
||||
}
|
||||
|
||||
await this.formPromise;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("updatedMasterPassword"),
|
||||
});
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.None,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (this.onSuccessfulChangePassword != null) {
|
||||
// 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.onSuccessfulChangePassword();
|
||||
} else {
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
private async updateTempPassword(masterPasswordHash: string, userKey: [UserKey, EncString]) {
|
||||
const request = new UpdateTempPasswordRequest();
|
||||
request.key = userKey[1].encryptedString;
|
||||
request.newMasterPasswordHash = masterPasswordHash;
|
||||
request.masterPasswordHint = this.hint;
|
||||
|
||||
return this.masterPasswordApiService.putUpdateTempPassword(request);
|
||||
}
|
||||
|
||||
private async updatePassword(newMasterPasswordHash: string, userKey: [UserKey, EncString]) {
|
||||
const request = await this.userVerificationService.buildRequest(
|
||||
this.verification,
|
||||
PasswordRequest,
|
||||
);
|
||||
request.masterPasswordHint = this.hint;
|
||||
request.newMasterPasswordHash = newMasterPasswordHash;
|
||||
request.key = userKey[1].encryptedString;
|
||||
|
||||
return this.masterPasswordApiService.postPassword(request);
|
||||
}
|
||||
|
||||
private async updateTdeOffboardingPassword(
|
||||
masterPasswordHash: string,
|
||||
userKey: [UserKey, EncString],
|
||||
) {
|
||||
const request = new UpdateTdeOffboardingPasswordRequest();
|
||||
request.key = userKey[1].encryptedString;
|
||||
request.newMasterPasswordHash = masterPasswordHash;
|
||||
request.masterPasswordHint = this.hint;
|
||||
|
||||
return this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
|
||||
|
||||
/**
|
||||
* Default implementation of the device management component service
|
||||
*/
|
||||
export class DefaultDeviceManagementComponentService
|
||||
implements DeviceManagementComponentServiceAbstraction
|
||||
{
|
||||
/**
|
||||
* Show header information in web client
|
||||
*/
|
||||
showHeaderInformation(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Service abstraction for device management component
|
||||
* Used to determine client-specific behavior
|
||||
*/
|
||||
export abstract class DeviceManagementComponentServiceAbstraction {
|
||||
/**
|
||||
* Whether to show header information (title, description, etc.) in the device management component
|
||||
* @returns true if header information should be shown, false otherwise
|
||||
*/
|
||||
abstract showHeaderInformation(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<bit-item-group>
|
||||
<bit-item *ngFor="let device of devices">
|
||||
@if (device.pendingAuthRequest) {
|
||||
<button
|
||||
class="tw-relative"
|
||||
bit-item-content
|
||||
type="button"
|
||||
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
|
||||
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
|
||||
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
|
||||
>
|
||||
<!-- Default Content -->
|
||||
<span class="tw-text-base">{{ device.displayName }}</span>
|
||||
|
||||
<!-- Default Trailing Content -->
|
||||
<span class="tw-absolute tw-top-[6px] tw-right-3" slot="default-trailing">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "requestPending" | i18n }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Secondary Content -->
|
||||
<span slot="secondary" class="tw-text-sm">
|
||||
<span>{{ "needsApproval" | i18n }}</span>
|
||||
<div>
|
||||
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
} @else {
|
||||
<bit-item-content ngClass="tw-relative">
|
||||
<!-- Default Content -->
|
||||
<span class="tw-text-base">{{ device.displayName }}</span>
|
||||
|
||||
<!-- Default Trailing Content -->
|
||||
<div
|
||||
*ngIf="device.isCurrentDevice"
|
||||
class="tw-absolute tw-top-[6px] tw-right-3"
|
||||
slot="default-trailing"
|
||||
>
|
||||
<span bitBadge variant="primary">
|
||||
{{ "currentSession" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Secondary Content -->
|
||||
<div slot="secondary" class="tw-text-sm">
|
||||
@if (device.isTrusted) {
|
||||
<span>{{ "trusted" | i18n }}</span>
|
||||
} @else {
|
||||
<br />
|
||||
}
|
||||
|
||||
<div>
|
||||
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</bit-item-content>
|
||||
}
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
|
||||
/** Displays user devices in an item list view */
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-device-management-item-group",
|
||||
templateUrl: "./device-management-item-group.component.html",
|
||||
imports: [BadgeModule, CommonModule, ItemModule, I18nPipe],
|
||||
})
|
||||
export class DeviceManagementItemGroupComponent {
|
||||
@Input() devices: DeviceDisplayData[] = [];
|
||||
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
if (pendingAuthRequest == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
|
||||
notificationId: pendingAuthRequest.id,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(loginApprovalDialog.closed);
|
||||
|
||||
if (result !== undefined && typeof result === "boolean") {
|
||||
// Auth request was approved or denied, so clear the
|
||||
// pending auth request and re-sort the device array
|
||||
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
|
||||
<!-- Table Header -->
|
||||
<ng-container header>
|
||||
<th
|
||||
*ngFor="let column of columnConfig"
|
||||
[class]="column.headerClass"
|
||||
bitCell
|
||||
[bitSortable]="column.sortable ? column.name : ''"
|
||||
[default]="column.name === 'loginStatus' ? 'desc' : false"
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
{{ column.title }}
|
||||
</th>
|
||||
</ng-container>
|
||||
|
||||
<!-- Table Rows -->
|
||||
<ng-template bitRowDef let-device>
|
||||
<!-- Column: Device Name -->
|
||||
<td bitCell class="tw-flex tw-gap-2">
|
||||
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
|
||||
<i [class]="device.icon" class="bwi-lg" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@if (device.pendingAuthRequest) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
|
||||
>
|
||||
{{ device.displayName }}
|
||||
</a>
|
||||
<div class="tw-text-sm tw-text-muted">
|
||||
{{ "needsApproval" | i18n }}
|
||||
</div>
|
||||
} @else {
|
||||
<span>{{ device.displayName }}</span>
|
||||
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">
|
||||
{{ "trusted" | i18n }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Column: Login Status -->
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-gap-1">
|
||||
<span *ngIf="device.isCurrentDevice" bitBadge variant="primary">
|
||||
{{ "currentSession" | i18n }}
|
||||
</span>
|
||||
<span *ngIf="device.pendingAuthRequest" bitBadge variant="warning">
|
||||
{{ "requestPending" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Column: First Login -->
|
||||
<td bitCell>{{ device.firstLogin | date: "medium" }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
LinkModule,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
|
||||
/** Displays user devices in a sortable table view */
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-device-management-table",
|
||||
templateUrl: "./device-management-table.component.html",
|
||||
imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule],
|
||||
})
|
||||
export class DeviceManagementTableComponent implements OnChanges {
|
||||
@Input() devices: DeviceDisplayData[] = [];
|
||||
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
|
||||
|
||||
protected readonly columnConfig = [
|
||||
{
|
||||
name: "displayName",
|
||||
title: this.i18nService.t("device"),
|
||||
headerClass: "tw-w-1/3",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "loginStatus",
|
||||
title: this.i18nService.t("loginStatus"),
|
||||
headerClass: "tw-w-1/3",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "firstLogin",
|
||||
title: this.i18nService.t("firstLogin"),
|
||||
headerClass: "tw-w-1/3",
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.devices) {
|
||||
this.tableDataSource.data = this.devices;
|
||||
}
|
||||
}
|
||||
|
||||
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
if (pendingAuthRequest == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
|
||||
notificationId: pendingAuthRequest.id,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(loginApprovalDialog.closed);
|
||||
|
||||
if (result !== undefined && typeof result === "boolean") {
|
||||
// Auth request was approved or denied, so clear the
|
||||
// pending auth request and re-sort the device array
|
||||
this.tableDataSource.data = clearAuthRequestAndResortDevices(
|
||||
this.devices,
|
||||
pendingAuthRequest,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<div *ngIf="showHeaderInfo" class="tw-mt-6 tw-mb-2 tw-pb-2.5">
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-mb-5">
|
||||
<h1 class="tw-m-0">{{ "devices" | i18n }}</h1>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-flex tw-items-center tw-size-4"
|
||||
[bitPopoverTriggerFor]="infoPopover"
|
||||
position="right-start"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
|
||||
<p>{{ "aDeviceIs" | i18n }}</p>
|
||||
</bit-popover>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{{ "deviceListDescriptionTemp" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (initializing) {
|
||||
<div class="tw-flex tw-justify-center tw-items-center tw-p-4">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Table View: displays on medium to large screens -->
|
||||
<auth-device-management-table
|
||||
ngClass="tw-hidden md:tw-block"
|
||||
[devices]="devices"
|
||||
></auth-device-management-table>
|
||||
|
||||
<!-- List View: displays on small screens -->
|
||||
<auth-device-management-item-group
|
||||
ngClass="md:tw-hidden"
|
||||
[devices]="devices"
|
||||
></auth-device-management-item-group>
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
import {
|
||||
DevicePendingAuthRequest,
|
||||
DeviceResponse,
|
||||
} from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
|
||||
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { ButtonModule, PopoverModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
|
||||
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
|
||||
import { DeviceManagementTableComponent } from "./device-management-table.component";
|
||||
|
||||
export interface DeviceDisplayData {
|
||||
displayName: string;
|
||||
firstLogin: Date;
|
||||
icon: string;
|
||||
id: string;
|
||||
identifier: string;
|
||||
isCurrentDevice: boolean;
|
||||
isTrusted: boolean;
|
||||
loginStatus: string;
|
||||
pendingAuthRequest: DevicePendingAuthRequest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `DeviceManagementComponent` fetches user devices and passes them down
|
||||
* to a child component for display.
|
||||
*
|
||||
* The specific child component that gets displayed depends on the viewport width:
|
||||
* - Medium to Large screens = `bit-table` view
|
||||
* - Small screens = `bit-item-group` view
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-device-management",
|
||||
templateUrl: "./device-management.component.html",
|
||||
imports: [
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
DeviceManagementItemGroupComponent,
|
||||
DeviceManagementTableComponent,
|
||||
I18nPipe,
|
||||
PopoverModule,
|
||||
],
|
||||
})
|
||||
export class DeviceManagementComponent implements OnInit {
|
||||
protected devices: DeviceDisplayData[] = [];
|
||||
protected initializing = true;
|
||||
protected showHeaderInfo = false;
|
||||
|
||||
constructor(
|
||||
private authRequestApiService: AuthRequestApiServiceAbstraction,
|
||||
private destroyRef: DestroyRef,
|
||||
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
|
||||
private devicesService: DevicesServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private messageListener: MessageListener,
|
||||
private validationService: ValidationService,
|
||||
) {
|
||||
this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation();
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDevices();
|
||||
|
||||
this.messageListener.allMessages$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((message) => {
|
||||
if (
|
||||
message.command === "openLoginApproval" &&
|
||||
message.notificationId &&
|
||||
typeof message.notificationId === "string"
|
||||
) {
|
||||
void this.upsertDeviceWithPendingAuthRequest(message.notificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadDevices() {
|
||||
try {
|
||||
const devices = await firstValueFrom(this.devicesService.getDevices$());
|
||||
const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$());
|
||||
|
||||
if (!devices || !currentDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.devices = this.mapDevicesToDisplayData(devices, currentDevice);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private mapDevicesToDisplayData(
|
||||
devices: DeviceView[],
|
||||
currentDevice: DeviceResponse,
|
||||
): DeviceDisplayData[] {
|
||||
return devices
|
||||
.map((device): DeviceDisplayData | null => {
|
||||
if (!device.id) {
|
||||
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (device.type == undefined) {
|
||||
this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing")));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!device.creationDate) {
|
||||
this.validationService.showError(
|
||||
new Error(this.i18nService.t("deviceCreationDateMissing")),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
|
||||
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
|
||||
icon: this.getDeviceIcon(device.type),
|
||||
id: device.id || "",
|
||||
identifier: device.identifier ?? "",
|
||||
isCurrentDevice: this.isCurrentDevice(device, currentDevice),
|
||||
isTrusted: device.response?.isTrusted ?? false,
|
||||
loginStatus: this.getLoginStatus(device, currentDevice),
|
||||
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
|
||||
};
|
||||
})
|
||||
.filter((device) => device !== null);
|
||||
}
|
||||
|
||||
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
|
||||
const authRequestResponse = await this.authRequestApiService.getAuthRequest(authRequestId);
|
||||
if (!authRequestResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const upsertDevice: DeviceDisplayData = {
|
||||
displayName: this.devicesService.getReadableDeviceTypeName(
|
||||
authRequestResponse.requestDeviceTypeValue,
|
||||
),
|
||||
firstLogin: new Date(authRequestResponse.creationDate),
|
||||
icon: this.getDeviceIcon(authRequestResponse.requestDeviceTypeValue),
|
||||
id: "",
|
||||
identifier: authRequestResponse.requestDeviceIdentifier,
|
||||
isCurrentDevice: false,
|
||||
isTrusted: false,
|
||||
loginStatus: this.i18nService.t("requestPending"),
|
||||
pendingAuthRequest: {
|
||||
id: authRequestResponse.id,
|
||||
creationDate: authRequestResponse.creationDate,
|
||||
},
|
||||
};
|
||||
|
||||
// If the device already exists in the DB, update the device id and first login date
|
||||
if (authRequestResponse.requestDeviceIdentifier) {
|
||||
const existingDevice = await firstValueFrom(
|
||||
this.devicesService.getDeviceByIdentifier$(authRequestResponse.requestDeviceIdentifier),
|
||||
);
|
||||
|
||||
if (existingDevice?.id && existingDevice.creationDate) {
|
||||
upsertDevice.id = existingDevice.id;
|
||||
upsertDevice.firstLogin = new Date(existingDevice.creationDate);
|
||||
}
|
||||
}
|
||||
|
||||
const existingDeviceIndex = this.devices.findIndex(
|
||||
(device) => device.identifier === upsertDevice.identifier,
|
||||
);
|
||||
|
||||
if (existingDeviceIndex >= 0) {
|
||||
// Update existing device in device list
|
||||
this.devices[existingDeviceIndex] = upsertDevice;
|
||||
this.devices = [...this.devices];
|
||||
} else {
|
||||
// Add new device to device list
|
||||
this.devices = [upsertDevice, ...this.devices];
|
||||
}
|
||||
}
|
||||
|
||||
private getLoginStatus(device: DeviceView, currentDevice: DeviceResponse): string {
|
||||
if (this.isCurrentDevice(device, currentDevice)) {
|
||||
return this.i18nService.t("currentSession");
|
||||
}
|
||||
|
||||
if (this.hasPendingAuthRequest(device)) {
|
||||
return this.i18nService.t("requestPending");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private isCurrentDevice(device: DeviceView, currentDevice: DeviceResponse): boolean {
|
||||
return device.id === currentDevice.id;
|
||||
}
|
||||
|
||||
private hasPendingAuthRequest(device: DeviceView): boolean {
|
||||
return device.response?.devicePendingAuthRequest != null;
|
||||
}
|
||||
|
||||
private getDeviceIcon(type: DeviceType): string {
|
||||
const defaultIcon = "bwi bwi-desktop";
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
webApp: "bwi bwi-browser",
|
||||
desktop: "bwi bwi-desktop",
|
||||
mobile: "bwi bwi-mobile",
|
||||
cli: "bwi bwi-cli",
|
||||
extension: "bwi bwi-puzzle",
|
||||
sdk: "bwi bwi-desktop",
|
||||
};
|
||||
|
||||
const metadata = DeviceTypeMetadata[type];
|
||||
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
|
||||
export function clearAuthRequestAndResortDevices(
|
||||
devices: DeviceDisplayData[],
|
||||
pendingAuthRequest: DevicePendingAuthRequest,
|
||||
): DeviceDisplayData[] {
|
||||
return devices
|
||||
.map((device) => {
|
||||
if (device.pendingAuthRequest?.id === pendingAuthRequest.id) {
|
||||
device.pendingAuthRequest = null;
|
||||
device.loginStatus = "";
|
||||
}
|
||||
return device;
|
||||
})
|
||||
.sort(resortDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a device is approved/denied, it will still be at the beginning of the array,
|
||||
* so we must resort the array to ensure it is in the correct order.
|
||||
*
|
||||
* This is a helper function that gets passed to the `Array.sort()` method
|
||||
*/
|
||||
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
|
||||
// Devices with a pending auth request should be first
|
||||
if (deviceA.pendingAuthRequest) {
|
||||
return -1;
|
||||
}
|
||||
if (deviceB.pendingAuthRequest) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Next is the current device
|
||||
if (deviceA.isCurrentDevice) {
|
||||
return -1;
|
||||
}
|
||||
if (deviceB.isCurrentDevice) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort the rest by display name (alphabetically)
|
||||
if (deviceA.displayName < deviceB.displayName) {
|
||||
return -1;
|
||||
}
|
||||
if (deviceA.displayName > deviceB.displayName) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Default
|
||||
return 0;
|
||||
}
|
||||
@@ -68,12 +68,9 @@ describe("AuthGuard", () => {
|
||||
{ path: "", component: EmptyComponent },
|
||||
{ path: "guarded-route", component: EmptyComponent, canActivate: [authGuard] },
|
||||
{ path: "lock", component: EmptyComponent },
|
||||
{ path: "set-password", component: EmptyComponent },
|
||||
{ path: "set-password-jit", component: EmptyComponent },
|
||||
{ path: "set-initial-password", component: EmptyComponent },
|
||||
{ path: "update-temp-password", component: EmptyComponent },
|
||||
{ path: "set-initial-password", component: EmptyComponent, canActivate: [authGuard] },
|
||||
{ path: "change-password", component: EmptyComponent },
|
||||
{ path: "remove-password", component: EmptyComponent },
|
||||
{ path: "remove-password", component: EmptyComponent, canActivate: [authGuard] },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
@@ -124,87 +121,59 @@ describe("AuthGuard", () => {
|
||||
expect(router.url).toBe("/remove-password");
|
||||
});
|
||||
|
||||
describe("given user is Unlocked", () => {
|
||||
describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
|
||||
const tests = [
|
||||
ForceSetPasswordReason.SsoNewJitProvisionedUser,
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
ForceSetPasswordReason.TdeOffboarding,
|
||||
];
|
||||
describe("given user is Locked", () => {
|
||||
it("should redirect to /set-initial-password when the user has ForceSetPasswordReaason.TdeOffboardingUntrustedDevice", async () => {
|
||||
const { router } = setup(
|
||||
AuthenticationStatus.Locked,
|
||||
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
|
||||
false,
|
||||
);
|
||||
|
||||
describe("given user attempts to navigate to an auth guarded route", () => {
|
||||
tests.forEach((reason) => {
|
||||
it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(
|
||||
AuthenticationStatus.Unlocked,
|
||||
reason,
|
||||
false,
|
||||
FeatureFlag.PM16117_SetInitialPasswordRefactor,
|
||||
);
|
||||
await router.navigate(["guarded-route"]);
|
||||
expect(router.url).toBe("/set-initial-password");
|
||||
});
|
||||
|
||||
await router.navigate(["guarded-route"]);
|
||||
expect(router.url).toContain("/set-initial-password");
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.TdeOffboardingUntrustedDevice", async () => {
|
||||
const { router } = setup(
|
||||
AuthenticationStatus.Unlocked,
|
||||
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
|
||||
false,
|
||||
);
|
||||
|
||||
describe("given user attempts to navigate to /set-initial-password", () => {
|
||||
tests.forEach((reason) => {
|
||||
it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(
|
||||
AuthenticationStatus.Unlocked,
|
||||
reason,
|
||||
false,
|
||||
FeatureFlag.PM16117_SetInitialPasswordRefactor,
|
||||
);
|
||||
await router.navigate(["/set-initial-password"]);
|
||||
expect(router.url).toContain("/set-initial-password");
|
||||
});
|
||||
});
|
||||
|
||||
await router.navigate(["/set-initial-password"]);
|
||||
expect(router.url).toContain("/set-initial-password");
|
||||
});
|
||||
describe("given user is Unlocked and ForceSetPasswordReason requires setting an initial password", () => {
|
||||
const tests = [
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
ForceSetPasswordReason.TdeOffboarding,
|
||||
];
|
||||
|
||||
describe("given user attempts to navigate to an auth guarded route", () => {
|
||||
tests.forEach((reason) => {
|
||||
it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(AuthenticationStatus.Unlocked, reason, false);
|
||||
|
||||
await router.navigate(["guarded-route"]);
|
||||
expect(router.url).toContain("/set-initial-password");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
|
||||
const tests = [
|
||||
{
|
||||
reason: ForceSetPasswordReason.SsoNewJitProvisionedUser,
|
||||
url: "/set-password-jit",
|
||||
},
|
||||
{
|
||||
reason: ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
url: "/set-password",
|
||||
},
|
||||
{
|
||||
reason: ForceSetPasswordReason.TdeOffboarding,
|
||||
url: "/update-temp-password",
|
||||
},
|
||||
];
|
||||
describe("given user attempts to navigate to /set-initial-password", () => {
|
||||
tests.forEach((reason) => {
|
||||
it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(AuthenticationStatus.Unlocked, reason, false);
|
||||
|
||||
describe("given user attempts to navigate to an auth guarded route", () => {
|
||||
tests.forEach(({ reason, url }) => {
|
||||
it(`should redirect to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(AuthenticationStatus.Unlocked, reason);
|
||||
|
||||
await router.navigate(["/guarded-route"]);
|
||||
expect(router.url).toContain(url);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given user attempts to navigate to the set- or update- password route itself", () => {
|
||||
tests.forEach(({ reason, url }) => {
|
||||
it(`should allow navigation to continue to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(AuthenticationStatus.Unlocked, reason);
|
||||
|
||||
await router.navigate([url]);
|
||||
expect(router.url).toContain(url);
|
||||
});
|
||||
await router.navigate(["/set-initial-password"]);
|
||||
expect(router.url).toContain("/set-initial-password");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is ON", () => {
|
||||
describe("given user is Unlocked and ForceSetPasswordReason requires changing an existing password", () => {
|
||||
const tests = [
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
@@ -213,12 +182,7 @@ describe("AuthGuard", () => {
|
||||
describe("given user attempts to navigate to an auth guarded route", () => {
|
||||
tests.forEach((reason) => {
|
||||
it(`should redirect to /change-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(
|
||||
AuthenticationStatus.Unlocked,
|
||||
reason,
|
||||
false,
|
||||
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
|
||||
);
|
||||
const { router } = setup(AuthenticationStatus.Unlocked, reason, false);
|
||||
|
||||
await router.navigate(["guarded-route"]);
|
||||
expect(router.url).toContain("/change-password");
|
||||
@@ -233,7 +197,6 @@ describe("AuthGuard", () => {
|
||||
AuthenticationStatus.Unlocked,
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
false,
|
||||
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
|
||||
);
|
||||
|
||||
await router.navigate(["/change-password"]);
|
||||
@@ -242,34 +205,5 @@ describe("AuthGuard", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is OFF", () => {
|
||||
const tests = [
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
];
|
||||
|
||||
describe("given user attempts to navigate to an auth guarded route", () => {
|
||||
tests.forEach((reason) => {
|
||||
it(`should redirect to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(AuthenticationStatus.Unlocked, reason);
|
||||
|
||||
await router.navigate(["guarded-route"]);
|
||||
expect(router.url).toContain("/update-temp-password");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given user attempts to navigate to /update-temp-password", () => {
|
||||
tests.forEach((reason) => {
|
||||
it(`should allow navigation to continue to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
|
||||
const { router } = setup(AuthenticationStatus.Unlocked, reason);
|
||||
|
||||
await router.navigate(["/update-temp-password"]);
|
||||
expect(router.url).toContain("/update-temp-password");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
|
||||
export const authGuard: CanActivateFn = async (
|
||||
@@ -30,7 +28,6 @@ export const authGuard: CanActivateFn = async (
|
||||
const keyConnectorService = inject(KeyConnectorService);
|
||||
const accountService = inject(AccountService);
|
||||
const masterPasswordService = inject(MasterPasswordServiceAbstraction);
|
||||
const configService = inject(ConfigService);
|
||||
|
||||
const authStatus = await authService.getAuthStatus();
|
||||
|
||||
@@ -39,7 +36,36 @@ export const authGuard: CanActivateFn = async (
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authStatus === AuthenticationStatus.Locked) {
|
||||
const userId = (await firstValueFrom(accountService.activeAccount$)).id;
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
masterPasswordService.forceSetPasswordReason$(userId),
|
||||
);
|
||||
|
||||
// User JIT provisioned into a master-password-encryption org
|
||||
if (
|
||||
authStatus === AuthenticationStatus.Locked &&
|
||||
forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser &&
|
||||
!routerState.url.includes("set-initial-password")
|
||||
) {
|
||||
return router.createUrlTree(["/set-initial-password"]);
|
||||
}
|
||||
|
||||
// TDE Offboarding on untrusted device
|
||||
if (
|
||||
authStatus === AuthenticationStatus.Locked &&
|
||||
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboardingUntrustedDevice &&
|
||||
!routerState.url.includes("set-initial-password")
|
||||
) {
|
||||
return router.createUrlTree(["/set-initial-password"]);
|
||||
}
|
||||
|
||||
// We must add exemptions for the SsoNewJitProvisionedUser and TdeOffboardingUntrustedDevice scenarios as
|
||||
// the "set-initial-password" route is guarded by the authGuard.
|
||||
if (
|
||||
authStatus === AuthenticationStatus.Locked &&
|
||||
forceSetPasswordReason !== ForceSetPasswordReason.SsoNewJitProvisionedUser &&
|
||||
forceSetPasswordReason !== ForceSetPasswordReason.TdeOffboardingUntrustedDevice
|
||||
) {
|
||||
if (routerState != null) {
|
||||
messagingService.send("lockedUrl", { url: routerState.url });
|
||||
}
|
||||
@@ -55,57 +81,28 @@ export const authGuard: CanActivateFn = async (
|
||||
return router.createUrlTree(["/remove-password"]);
|
||||
}
|
||||
|
||||
const userId = (await firstValueFrom(accountService.activeAccount$)).id;
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
masterPasswordService.forceSetPasswordReason$(userId),
|
||||
);
|
||||
|
||||
const isSetInitialPasswordFlagOn = await configService.getFeatureFlag(
|
||||
FeatureFlag.PM16117_SetInitialPasswordRefactor,
|
||||
);
|
||||
const isChangePasswordFlagOn = await configService.getFeatureFlag(
|
||||
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
|
||||
);
|
||||
|
||||
// User JIT provisioned into a master-password-encryption org
|
||||
// Handle cases where a user needs to set a password when they don't already have one:
|
||||
// - TDE org user has been given "manage account recovery" permission
|
||||
// - TDE offboarding on a trusted device, where we have access to their encryption key wrap with their new password
|
||||
if (
|
||||
forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser &&
|
||||
!routerState.url.includes("set-password-jit") &&
|
||||
(forceSetPasswordReason ===
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission ||
|
||||
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) &&
|
||||
!routerState.url.includes("set-initial-password")
|
||||
) {
|
||||
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password-jit";
|
||||
const route = "/set-initial-password";
|
||||
return router.createUrlTree([route]);
|
||||
}
|
||||
|
||||
// TDE org user has "manage account recovery" permission
|
||||
// Handle cases where a user has a password but needs to set a new one:
|
||||
// - Account recovery
|
||||
// - Weak Password on login
|
||||
if (
|
||||
forceSetPasswordReason ===
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission &&
|
||||
!routerState.url.includes("set-password") &&
|
||||
!routerState.url.includes("set-initial-password")
|
||||
(forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
|
||||
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) &&
|
||||
!routerState.url.includes("change-password")
|
||||
) {
|
||||
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password";
|
||||
return router.createUrlTree([route]);
|
||||
}
|
||||
|
||||
// TDE Offboarding
|
||||
if (
|
||||
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding &&
|
||||
!routerState.url.includes("update-temp-password") &&
|
||||
!routerState.url.includes("set-initial-password")
|
||||
) {
|
||||
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/update-temp-password";
|
||||
return router.createUrlTree([route]);
|
||||
}
|
||||
|
||||
// Post- Account Recovery or Weak Password on login
|
||||
if (
|
||||
forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
|
||||
(forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword &&
|
||||
!routerState.url.includes("update-temp-password") &&
|
||||
!routerState.url.includes("change-password"))
|
||||
) {
|
||||
const route = isChangePasswordFlagOn ? "/change-password" : "/update-temp-password";
|
||||
const route = "/change-password";
|
||||
return router.createUrlTree([route]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./lock.guard";
|
||||
export * from "./redirect/redirect.guard";
|
||||
export * from "./tde-decryption-required.guard";
|
||||
export * from "./unauth.guard";
|
||||
export * from "./redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard";
|
||||
|
||||
@@ -26,7 +26,6 @@ import { lockGuard } from "./lock.guard";
|
||||
interface SetupParams {
|
||||
authStatus: AuthenticationStatus;
|
||||
canLock?: boolean;
|
||||
isLegacyUser?: boolean;
|
||||
clientType?: ClientType;
|
||||
everHadUserKey?: boolean;
|
||||
supportsDeviceTrust?: boolean;
|
||||
@@ -43,7 +42,6 @@ describe("lockGuard", () => {
|
||||
vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock);
|
||||
|
||||
const keyService: MockProxy<KeyService> = mock<KeyService>();
|
||||
keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser);
|
||||
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey));
|
||||
|
||||
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
|
||||
@@ -155,37 +153,10 @@ describe("lockGuard", () => {
|
||||
expect(router.url).toBe("/");
|
||||
});
|
||||
|
||||
it("should log user out if they are a legacy user on a desktop client", async () => {
|
||||
const { router, messagingService } = setup({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
canLock: true,
|
||||
isLegacyUser: true,
|
||||
clientType: ClientType.Desktop,
|
||||
});
|
||||
|
||||
await router.navigate(["lock"]);
|
||||
expect(router.url).toBe("/");
|
||||
expect(messagingService.send).toHaveBeenCalledWith("logout");
|
||||
});
|
||||
|
||||
it("should log user out if they are a legacy user on a browser extension client", async () => {
|
||||
const { router, messagingService } = setup({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
canLock: true,
|
||||
isLegacyUser: true,
|
||||
clientType: ClientType.Browser,
|
||||
});
|
||||
|
||||
await router.navigate(["lock"]);
|
||||
expect(router.url).toBe("/");
|
||||
expect(messagingService.send).toHaveBeenCalledWith("logout");
|
||||
});
|
||||
|
||||
it("should allow navigation to the lock route when device trust is supported, the user has a MP, and the user is coming from the login-initiated page", async () => {
|
||||
const { router } = setup({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
canLock: true,
|
||||
isLegacyUser: false,
|
||||
clientType: ClientType.Web,
|
||||
everHadUserKey: false,
|
||||
supportsDeviceTrust: true,
|
||||
@@ -213,7 +184,6 @@ describe("lockGuard", () => {
|
||||
const { router } = setup({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
canLock: true,
|
||||
isLegacyUser: false,
|
||||
clientType: ClientType.Web,
|
||||
everHadUserKey: false,
|
||||
supportsDeviceTrust: true,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
/**
|
||||
@@ -31,7 +30,6 @@ export function lockGuard(): CanActivateFn {
|
||||
const authService = inject(AuthService);
|
||||
const keyService = inject(KeyService);
|
||||
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
|
||||
const messagingService = inject(MessagingService);
|
||||
const router = inject(Router);
|
||||
const userVerificationService = inject(UserVerificationService);
|
||||
const vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
@@ -56,11 +54,6 @@ export function lockGuard(): CanActivateFn {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await keyService.isLegacyUser()) {
|
||||
messagingService.send("logout");
|
||||
return false;
|
||||
}
|
||||
|
||||
// User is authN and in locked state.
|
||||
|
||||
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# RedirectToVaultIfUnlocked Guard
|
||||
|
||||
The `redirectToVaultIfUnlocked` redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route.
|
||||
|
||||
This is particularly useful for routes that can handle BOTH unauthenticated AND authenticated-but-locked users (which makes the `authGuard` unusable on those routes).
|
||||
|
||||
<br>
|
||||
|
||||
### Special Use Case - Authenticating in the Extension Popout
|
||||
|
||||
Imagine a user is going through the Login with Device flow in the Extension pop*out*:
|
||||
|
||||
- They open the pop*out* while on `/login-with-device`
|
||||
- The approve the login from another device
|
||||
- They are authenticated and routed to `/vault` while in the pop*out*
|
||||
|
||||
If the `redirectToVaultIfUnlocked` were NOT applied, if this user now opens the pop*up* they would be shown the `/login-with-device`, not their `/vault`.
|
||||
|
||||
But by adding the `redirectToVaultIfUnlocked` to `/login-with-device` we make sure to check if the user has already `Unlocked`, and if so, route them to `/vault` upon opening the pop*up*.
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router, provideRouter } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard";
|
||||
|
||||
describe("redirectToVaultIfUnlockedGuard", () => {
|
||||
const activeUser: Account = {
|
||||
id: "userId" as UserId,
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => {
|
||||
const accountService = mock<AccountService>();
|
||||
const authService = mock<AuthService>();
|
||||
|
||||
accountService.activeAccount$ = new BehaviorSubject<Account | null>(activeUser);
|
||||
authService.authStatusFor$.mockReturnValue(of(authStatus));
|
||||
|
||||
const testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
provideRouter([
|
||||
{ path: "", component: EmptyComponent },
|
||||
{ path: "vault", component: EmptyComponent },
|
||||
{
|
||||
path: "guarded-route",
|
||||
component: EmptyComponent,
|
||||
canActivate: [redirectToVaultIfUnlockedGuard()],
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
router: testBed.inject(Router),
|
||||
};
|
||||
};
|
||||
|
||||
it("should be created", () => {
|
||||
const { router } = setup(null, null);
|
||||
expect(router).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should redirect to /vault if the user is AuthenticationStatus.Unlocked", async () => {
|
||||
// Arrange
|
||||
const { router } = setup(activeUser, AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await router.navigate(["guarded-route"]);
|
||||
|
||||
// Assert
|
||||
expect(router.url).toBe("/vault");
|
||||
});
|
||||
|
||||
it("should allow navigation to continue to the route if there is no active user", async () => {
|
||||
// Arrange
|
||||
const { router } = setup(null, null);
|
||||
|
||||
// Act
|
||||
await router.navigate(["guarded-route"]);
|
||||
|
||||
// Assert
|
||||
expect(router.url).toBe("/guarded-route");
|
||||
});
|
||||
|
||||
it("should allow navigation to continue to the route if the user is AuthenticationStatus.LoggedOut", async () => {
|
||||
// Arrange
|
||||
const { router } = setup(null, AuthenticationStatus.LoggedOut);
|
||||
|
||||
// Act
|
||||
await router.navigate(["guarded-route"]);
|
||||
|
||||
// Assert
|
||||
expect(router.url).toBe("/guarded-route");
|
||||
});
|
||||
|
||||
it("should allow navigation to continue to the route if the user is AuthenticationStatus.Locked", async () => {
|
||||
// Arrange
|
||||
const { router } = setup(null, AuthenticationStatus.Locked);
|
||||
|
||||
// Act
|
||||
await router.navigate(["guarded-route"]);
|
||||
|
||||
// Assert
|
||||
expect(router.url).toBe("/guarded-route");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
/**
|
||||
* Redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route.
|
||||
* See ./redirect-to-vault-if-unlocked/README.md for more details.
|
||||
*/
|
||||
export function redirectToVaultIfUnlockedGuard(): CanActivateFn {
|
||||
return async () => {
|
||||
const accountService = inject(AccountService);
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
const activeUser = await firstValueFrom(accountService.activeAccount$);
|
||||
|
||||
// If there is no active user, allow access to the route
|
||||
if (!activeUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authStatus = await firstValueFrom(authService.authStatusFor$(activeUser.id));
|
||||
|
||||
// If user is Unlocked, redirect to vault
|
||||
if (authStatus === AuthenticationStatus.Unlocked) {
|
||||
return router.createUrlTree(["/vault"]);
|
||||
}
|
||||
|
||||
// If user is LoggedOut or Locked, allow access to the route
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
} @else {
|
||||
<bit-callout
|
||||
*ngIf="this.forceSetPasswordReason !== ForceSetPasswordReason.AdminForcePasswordReset"
|
||||
type="warning"
|
||||
>{{ "changePasswordWarning" | i18n }}</bit-callout
|
||||
>
|
||||
|
||||
<auth-input-password
|
||||
[flow]="inputPasswordFlow"
|
||||
[email]="email"
|
||||
@@ -15,6 +21,8 @@
|
||||
[inlineButtons]="true"
|
||||
[primaryButtonText]="{ key: 'changeMasterPassword' }"
|
||||
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
|
||||
[secondaryButtonText]="secondaryButtonText()"
|
||||
(onSecondaryButtonClick)="logOut()"
|
||||
>
|
||||
</auth-input-password>
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
InputPasswordComponent,
|
||||
InputPasswordFlow,
|
||||
PasswordInputResult,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
DialogService,
|
||||
ToastService,
|
||||
Icons,
|
||||
CalloutComponent,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { ChangePasswordService } from "./change-password.service.abstraction";
|
||||
|
||||
/**
|
||||
* Change Password Component
|
||||
*
|
||||
* NOTE: The change password component uses the input-password component which will show the
|
||||
* current password input form in some flows, although it could be left off. This is intentional
|
||||
* and by design to maintain a strong security posture as some flows could have the user
|
||||
* end up at a change password without having one before.
|
||||
*/
|
||||
@Component({
|
||||
selector: "auth-change-password",
|
||||
templateUrl: "change-password.component.html",
|
||||
imports: [InputPasswordComponent, I18nPipe, CalloutComponent, CommonModule],
|
||||
})
|
||||
export class ChangePasswordComponent implements OnInit {
|
||||
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
|
||||
|
||||
activeAccount: Account | null = null;
|
||||
email?: string;
|
||||
userId?: UserId;
|
||||
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
|
||||
initializing = true;
|
||||
submitting = false;
|
||||
formPromise?: Promise<any>;
|
||||
forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
|
||||
|
||||
protected readonly ForceSetPasswordReason = ForceSetPasswordReason;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private changePasswordService: ChangePasswordService,
|
||||
private i18nService: I18nService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private organizationInviteService: OrganizationInviteService,
|
||||
private messagingService: MessagingService,
|
||||
private policyService: PolicyService,
|
||||
private toastService: ToastService,
|
||||
private syncService: SyncService,
|
||||
private dialogService: DialogService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
if (!this.activeAccount) {
|
||||
throw new Error("No active active account found while trying to change passwords.");
|
||||
}
|
||||
|
||||
this.userId = this.activeAccount.id;
|
||||
this.email = this.activeAccount.email;
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Error("userId not found");
|
||||
}
|
||||
|
||||
this.masterPasswordPolicyOptions = await firstValueFrom(
|
||||
this.policyService.masterPasswordPolicyOptions$(this.userId),
|
||||
);
|
||||
|
||||
this.forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(this.userId),
|
||||
);
|
||||
|
||||
if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageIcon: Icons.LockIcon,
|
||||
pageTitle: { key: "updateMasterPassword" },
|
||||
pageSubtitle: { key: "accountRecoveryUpdateMasterPasswordSubtitle" },
|
||||
});
|
||||
} else if (this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageIcon: Icons.LockIcon,
|
||||
pageTitle: { key: "updateMasterPassword" },
|
||||
pageSubtitle: { key: "updateMasterPasswordSubtitle" },
|
||||
maxWidth: "lg",
|
||||
});
|
||||
}
|
||||
|
||||
this.initializing = false;
|
||||
}
|
||||
|
||||
async logOut() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "logOut" },
|
||||
content: { key: "logOutConfirmation" },
|
||||
acceptButtonText: { key: "logOut" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
|
||||
if (this.changePasswordService.clearDeeplinkState) {
|
||||
await this.changePasswordService.clearDeeplinkState();
|
||||
}
|
||||
|
||||
// TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
}
|
||||
|
||||
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
|
||||
this.submitting = true;
|
||||
|
||||
try {
|
||||
if (passwordInputResult.rotateUserKey) {
|
||||
if (this.activeAccount == null) {
|
||||
throw new Error("activeAccount not found");
|
||||
}
|
||||
|
||||
if (
|
||||
passwordInputResult.currentPassword == null ||
|
||||
passwordInputResult.newPasswordHint == null
|
||||
) {
|
||||
throw new Error("currentPassword or newPasswordHint not found");
|
||||
}
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData(
|
||||
passwordInputResult.currentPassword,
|
||||
passwordInputResult.newPassword,
|
||||
this.activeAccount,
|
||||
passwordInputResult.newPasswordHint,
|
||||
);
|
||||
} else {
|
||||
if (!this.userId) {
|
||||
throw new Error("userId not found");
|
||||
}
|
||||
|
||||
if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
await this.changePasswordService.changePasswordForAccountRecovery(
|
||||
passwordInputResult,
|
||||
this.userId,
|
||||
);
|
||||
} else {
|
||||
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("masterPasswordChanged"),
|
||||
});
|
||||
|
||||
// TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies
|
||||
this.messagingService.send("logout");
|
||||
|
||||
// Close the popout if we are in a browser extension popout.
|
||||
this.changePasswordService.closeBrowserExtensionPopout?.();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the logout button in the case of admin force reset password or weak password upon login.
|
||||
*/
|
||||
protected secondaryButtonText(): { key: string } | undefined {
|
||||
return this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
|
||||
this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword
|
||||
? { key: "logOut" }
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PasswordInputResult } from "@bitwarden/auth/angular";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -32,5 +34,35 @@ export abstract class ChangePasswordService {
|
||||
* @param userId the `userId`
|
||||
* @throws if the `userId`, `currentMasterKey`, or `currentServerMasterKeyHash` is not found
|
||||
*/
|
||||
abstract changePassword(passwordInputResult: PasswordInputResult, userId: UserId): Promise<void>;
|
||||
abstract changePassword(
|
||||
passwordInputResult: PasswordInputResult,
|
||||
userId: UserId | null,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Changes the user's password and re-encrypts the user key with the `newMasterKey`.
|
||||
* - Specifically, this method uses credentials from the `passwordInputResult` to:
|
||||
* 1. Decrypt the user key with the `currentMasterKey`
|
||||
* 2. Re-encrypt that user key with the `newMasterKey`, resulting in a `newMasterKeyEncryptedUserKey`
|
||||
* 3. Build a `PasswordRequest` object that gets PUTed to `"/accounts/update-temp-password"` so that the
|
||||
* ForcePasswordReset gets set to false.
|
||||
* @param passwordInputResult
|
||||
* @param userId
|
||||
*/
|
||||
abstract changePasswordForAccountRecovery(
|
||||
passwordInputResult: PasswordInputResult,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional method that will clear up any deep link state.
|
||||
* - Currently only used on the web change password service.
|
||||
*/
|
||||
clearDeeplinkState?: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional method that closes the browser extension popout if in a popout
|
||||
* If not in a popout, does nothing.
|
||||
*/
|
||||
abstract closeBrowserExtensionPopout?(): void;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PasswordInputResult } from "@bitwarden/auth/angular";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { PasswordInputResult } from "../input-password/password-input-result";
|
||||
|
||||
import { ChangePasswordService } from "./change-password.service.abstraction";
|
||||
import { DefaultChangePasswordService } from "./default-change-password.service";
|
||||
|
||||
@@ -109,7 +110,7 @@ describe("DefaultChangePasswordService", () => {
|
||||
it("should throw if a currentMasterKey was not found", async () => {
|
||||
// Arrange
|
||||
const incorrectPasswordInputResult = { ...passwordInputResult };
|
||||
incorrectPasswordInputResult.currentMasterKey = null;
|
||||
incorrectPasswordInputResult.currentMasterKey = undefined;
|
||||
|
||||
// Act
|
||||
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
|
||||
@@ -123,7 +124,7 @@ describe("DefaultChangePasswordService", () => {
|
||||
it("should throw if a currentServerMasterKeyHash was not found", async () => {
|
||||
// Arrange
|
||||
const incorrectPasswordInputResult = { ...passwordInputResult };
|
||||
incorrectPasswordInputResult.currentServerMasterKeyHash = null;
|
||||
incorrectPasswordInputResult.currentServerMasterKeyHash = undefined;
|
||||
|
||||
// Act
|
||||
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
|
||||
@@ -174,4 +175,43 @@ describe("DefaultChangePasswordService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changePasswordForAccountRecovery()", () => {
|
||||
it("should call the putUpdateTempPassword() API method with the correct UpdateTempPasswordRequest credentials", async () => {
|
||||
// Act
|
||||
await sut.changePasswordForAccountRecovery(passwordInputResult, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
newMasterPasswordHash: passwordInputResult.newServerMasterKeyHash,
|
||||
masterPasswordHint: passwordInputResult.newPasswordHint,
|
||||
key: newMasterKeyEncryptedUserKey[1].encryptedString,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if user key decryption fails", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId);
|
||||
|
||||
// Assert
|
||||
await expect(testFn).rejects.toThrow("Could not decrypt user key");
|
||||
});
|
||||
|
||||
it("should throw an error if putUpdateTempPassword() fails", async () => {
|
||||
// Arrange
|
||||
masterPasswordApiService.putUpdateTempPassword.mockRejectedValueOnce(new Error("error"));
|
||||
|
||||
// Act
|
||||
const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId);
|
||||
|
||||
// Assert
|
||||
await expect(testFn).rejects.toThrow("Could not change password");
|
||||
expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { PasswordInputResult, ChangePasswordService } from "@bitwarden/auth/angular";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PasswordInputResult } from "@bitwarden/auth/angular";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
|
||||
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ChangePasswordService } from "./change-password.service.abstraction";
|
||||
|
||||
export class DefaultChangePasswordService implements ChangePasswordService {
|
||||
constructor(
|
||||
protected keyService: KeyService,
|
||||
@@ -22,7 +29,11 @@ export class DefaultChangePasswordService implements ChangePasswordService {
|
||||
throw new Error("rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web");
|
||||
}
|
||||
|
||||
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId) {
|
||||
private async preparePasswordChange(
|
||||
passwordInputResult: PasswordInputResult,
|
||||
userId: UserId | null,
|
||||
request: PasswordRequest | UpdateTempPasswordRequest,
|
||||
): Promise<[UserKey, EncString]> {
|
||||
if (!userId) {
|
||||
throw new Error("userId not found");
|
||||
}
|
||||
@@ -45,15 +56,32 @@ export class DefaultChangePasswordService implements ChangePasswordService {
|
||||
throw new Error("Could not decrypt user key");
|
||||
}
|
||||
|
||||
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
|
||||
const newKeyValue = await this.keyService.encryptUserKeyWithMasterKey(
|
||||
passwordInputResult.newMasterKey,
|
||||
decryptedUserKey,
|
||||
);
|
||||
|
||||
if (request instanceof PasswordRequest) {
|
||||
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
|
||||
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
|
||||
request.masterPasswordHint = passwordInputResult.newPasswordHint;
|
||||
} else if (request instanceof UpdateTempPasswordRequest) {
|
||||
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
|
||||
request.masterPasswordHint = passwordInputResult.newPasswordHint;
|
||||
}
|
||||
|
||||
return newKeyValue;
|
||||
}
|
||||
|
||||
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId | null) {
|
||||
const request = new PasswordRequest();
|
||||
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
|
||||
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
|
||||
request.masterPasswordHint = passwordInputResult.newPasswordHint;
|
||||
|
||||
const newMasterKeyEncryptedUserKey = await this.preparePasswordChange(
|
||||
passwordInputResult,
|
||||
userId,
|
||||
request,
|
||||
);
|
||||
|
||||
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
|
||||
|
||||
try {
|
||||
@@ -62,4 +90,23 @@ export class DefaultChangePasswordService implements ChangePasswordService {
|
||||
throw new Error("Could not change password");
|
||||
}
|
||||
}
|
||||
|
||||
async changePasswordForAccountRecovery(passwordInputResult: PasswordInputResult, userId: UserId) {
|
||||
const request = new UpdateTempPasswordRequest();
|
||||
|
||||
const newMasterKeyEncryptedUserKey = await this.preparePasswordChange(
|
||||
passwordInputResult,
|
||||
userId,
|
||||
request,
|
||||
);
|
||||
|
||||
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
|
||||
|
||||
try {
|
||||
// TODO: PM-23047 will look to consolidate this into the change password endpoint.
|
||||
await this.masterPasswordApiService.putUpdateTempPassword(request);
|
||||
} catch {
|
||||
throw new Error("Could not change password");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./change-password.component";
|
||||
export * from "./change-password.service.abstraction";
|
||||
export * from "./default-change-password.service";
|
||||
@@ -0,0 +1,290 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordEnrollmentRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordUserType,
|
||||
SetInitialPasswordTdeOffboardingCredentials,
|
||||
} from "./set-initial-password.service.abstraction";
|
||||
|
||||
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
|
||||
constructor(
|
||||
protected apiService: ApiService,
|
||||
protected encryptService: EncryptService,
|
||||
protected i18nService: I18nService,
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
protected keyService: KeyService,
|
||||
protected masterPasswordApiService: MasterPasswordApiService,
|
||||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected organizationUserApiService: OrganizationUserApiService,
|
||||
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async setInitialPassword(
|
||||
credentials: SetInitialPasswordCredentials,
|
||||
userType: SetInitialPasswordUserType,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
const {
|
||||
newMasterKey,
|
||||
newServerMasterKeyHash,
|
||||
newLocalMasterKeyHash,
|
||||
newPasswordHint,
|
||||
kdfConfig,
|
||||
orgSsoIdentifier,
|
||||
orgId,
|
||||
resetPasswordAutoEnroll,
|
||||
} = credentials;
|
||||
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value == null) {
|
||||
throw new Error(`${key} not found. Could not set password.`);
|
||||
}
|
||||
}
|
||||
if (userId == null) {
|
||||
throw new Error("userId not found. Could not set password.");
|
||||
}
|
||||
if (userType == null) {
|
||||
throw new Error("userType not found. Could not set password.");
|
||||
}
|
||||
|
||||
const masterKeyEncryptedUserKey = await this.makeMasterKeyEncryptedUserKey(
|
||||
newMasterKey,
|
||||
userId,
|
||||
);
|
||||
if (masterKeyEncryptedUserKey == null || !masterKeyEncryptedUserKey[1].encryptedString) {
|
||||
throw new Error("masterKeyEncryptedUserKey not found. Could not set password.");
|
||||
}
|
||||
|
||||
let keyPair: [string, EncString] | null = null;
|
||||
let keysRequest: KeysRequest | null = null;
|
||||
|
||||
if (userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
|
||||
/**
|
||||
* A user being JIT provisioned into a MP encryption org does not yet have a user
|
||||
* asymmetric key pair, so we create it for them here.
|
||||
*
|
||||
* Sidenote:
|
||||
* In the case of a TDE user whose permissions require that they have a MP - that user
|
||||
* will already have a user asymmetric key pair by this point, so we skip this if-block
|
||||
* so that we don't create a new key pair for them.
|
||||
*/
|
||||
|
||||
// Extra safety check (see description on https://github.com/bitwarden/clients/pull/10180):
|
||||
// In case we have have a local private key and are not sure whether it has been posted to the server,
|
||||
// we post the local private key instead of generating a new one
|
||||
const existingUserPrivateKey = (await firstValueFrom(
|
||||
this.keyService.userPrivateKey$(userId),
|
||||
)) as Uint8Array;
|
||||
|
||||
const existingUserPublicKey = await firstValueFrom(this.keyService.userPublicKey$(userId));
|
||||
|
||||
if (existingUserPrivateKey != null && existingUserPublicKey != null) {
|
||||
const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey);
|
||||
|
||||
// Existing key pair
|
||||
keyPair = [
|
||||
existingUserPublicKeyB64,
|
||||
await this.encryptService.wrapDecapsulationKey(
|
||||
existingUserPrivateKey,
|
||||
masterKeyEncryptedUserKey[0],
|
||||
),
|
||||
];
|
||||
} else {
|
||||
// New key pair
|
||||
keyPair = await this.keyService.makeKeyPair(masterKeyEncryptedUserKey[0]);
|
||||
}
|
||||
|
||||
if (keyPair == null) {
|
||||
throw new Error("keyPair not found. Could not set password.");
|
||||
}
|
||||
if (!keyPair[1].encryptedString) {
|
||||
throw new Error("encrypted private key not found. Could not set password.");
|
||||
}
|
||||
|
||||
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
|
||||
}
|
||||
|
||||
const request = new SetPasswordRequest(
|
||||
newServerMasterKeyHash,
|
||||
masterKeyEncryptedUserKey[1].encryptedString,
|
||||
newPasswordHint,
|
||||
orgSsoIdentifier,
|
||||
keysRequest,
|
||||
kdfConfig.kdfType,
|
||||
kdfConfig.iterations,
|
||||
);
|
||||
|
||||
await this.masterPasswordApiService.setPassword(request);
|
||||
|
||||
// Clear force set password reason to allow navigation back to vault.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||
|
||||
// User now has a password so update account decryption options in state
|
||||
await this.updateAccountDecryptionProperties(
|
||||
newMasterKey,
|
||||
kdfConfig,
|
||||
masterKeyEncryptedUserKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
/**
|
||||
* Set the private key only for new JIT provisioned users in MP encryption orgs.
|
||||
* (Existing TDE users will have their private key set on sync or on login.)
|
||||
*/
|
||||
if (keyPair != null && userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
|
||||
if (!keyPair[1].encryptedString) {
|
||||
throw new Error("encrypted private key not found. Could not set private key in state.");
|
||||
}
|
||||
await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId);
|
||||
}
|
||||
|
||||
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
|
||||
|
||||
if (resetPasswordAutoEnroll) {
|
||||
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async makeMasterKeyEncryptedUserKey(
|
||||
masterKey: MasterKey,
|
||||
userId: UserId,
|
||||
): Promise<[UserKey, EncString]> {
|
||||
let masterKeyEncryptedUserKey: [UserKey, EncString] | null = null;
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
if (userKey == null) {
|
||||
masterKeyEncryptedUserKey = await this.keyService.makeUserKey(masterKey);
|
||||
} else {
|
||||
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey);
|
||||
}
|
||||
|
||||
return masterKeyEncryptedUserKey;
|
||||
}
|
||||
|
||||
private async updateAccountDecryptionProperties(
|
||||
masterKey: MasterKey,
|
||||
kdfConfig: KdfConfig,
|
||||
masterKeyEncryptedUserKey: [UserKey, EncString],
|
||||
userId: UserId,
|
||||
) {
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
userDecryptionOpts.hasMasterPassword = true;
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
|
||||
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
|
||||
}
|
||||
|
||||
private async handleResetPasswordAutoEnroll(
|
||||
masterKeyHash: string,
|
||||
orgId: string,
|
||||
userId: UserId,
|
||||
) {
|
||||
const organizationKeys = await this.organizationApiService.getKeys(orgId);
|
||||
|
||||
if (organizationKeys == null) {
|
||||
throw new Error(
|
||||
"Organization keys response is null. Could not handle reset password auto enroll.",
|
||||
);
|
||||
}
|
||||
|
||||
const orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
if (userKey == null) {
|
||||
throw new Error("userKey not found. Could not handle reset password auto enroll.");
|
||||
}
|
||||
|
||||
// RSA encrypt user key with organization public key
|
||||
const orgPublicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
userKey,
|
||||
orgPublicKey,
|
||||
);
|
||||
|
||||
if (orgPublicKeyEncryptedUserKey == null || !orgPublicKeyEncryptedUserKey.encryptedString) {
|
||||
throw new Error(
|
||||
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
|
||||
);
|
||||
}
|
||||
|
||||
const enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
enrollmentRequest.masterPasswordHash = masterKeyHash;
|
||||
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
|
||||
|
||||
await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
|
||||
orgId,
|
||||
userId,
|
||||
enrollmentRequest,
|
||||
);
|
||||
}
|
||||
|
||||
async setInitialPasswordTdeOffboarding(
|
||||
credentials: SetInitialPasswordTdeOffboardingCredentials,
|
||||
userId: UserId,
|
||||
) {
|
||||
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value == null) {
|
||||
throw new Error(`${key} not found. Could not set password.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (userId == null) {
|
||||
throw new Error("userId not found. Could not set password.");
|
||||
}
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("userKey not found. Could not set password.");
|
||||
}
|
||||
|
||||
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
|
||||
newMasterKey,
|
||||
userKey,
|
||||
);
|
||||
|
||||
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
|
||||
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
|
||||
}
|
||||
|
||||
const request = new UpdateTdeOffboardingPasswordRequest();
|
||||
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
|
||||
request.newMasterPasswordHash = newServerMasterKeyHash;
|
||||
request.masterPasswordHint = newPasswordHint;
|
||||
|
||||
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
|
||||
|
||||
// Clear force set password reason to allow navigation back to vault.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordEnrollmentRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
FakeUserDecryptionOptions as UserDecryptionOptions,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation";
|
||||
import {
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordTdeOffboardingCredentials,
|
||||
SetInitialPasswordUserType,
|
||||
} from "./set-initial-password.service.abstraction";
|
||||
|
||||
describe("DefaultSetInitialPasswordService", () => {
|
||||
let sut: SetInitialPasswordService;
|
||||
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
|
||||
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
|
||||
let userId: UserId;
|
||||
let userKey: UserKey;
|
||||
let userKeyEncString: EncString;
|
||||
let masterKeyEncryptedUserKey: [UserKey, EncString];
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
i18nService = mock<I18nService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
keyService = mock<KeyService>();
|
||||
masterPasswordApiService = mock<MasterPasswordApiService>();
|
||||
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
organizationUserApiService = mock<OrganizationUserApiService>();
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
|
||||
userId = "userId" as UserId;
|
||||
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
|
||||
userKeyEncString = new EncString("masterKeyEncryptedUserKey");
|
||||
masterKeyEncryptedUserKey = [userKey, userKeyEncString];
|
||||
|
||||
sut = new DefaultSetInitialPasswordService(
|
||||
apiService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
kdfConfigService,
|
||||
keyService,
|
||||
masterPasswordApiService,
|
||||
masterPasswordService,
|
||||
organizationApiService,
|
||||
organizationUserApiService,
|
||||
userDecryptionOptionsService,
|
||||
);
|
||||
});
|
||||
|
||||
it("should instantiate", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("setInitialPassword(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordCredentials;
|
||||
let userType: SetInitialPasswordUserType;
|
||||
|
||||
// Mock other function data
|
||||
let existingUserPublicKey: UserPublicKey;
|
||||
let existingUserPrivateKey: UserPrivateKey;
|
||||
let userKeyEncryptedPrivateKey: EncString;
|
||||
|
||||
let keyPair: [string, EncString];
|
||||
let keysRequest: KeysRequest;
|
||||
|
||||
let organizationKeys: OrganizationKeysResponse;
|
||||
let orgPublicKeyEncryptedUserKey: EncString;
|
||||
|
||||
let userDecryptionOptions: UserDecryptionOptions;
|
||||
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
|
||||
let setPasswordRequest: SetPasswordRequest;
|
||||
|
||||
let enrollmentRequest: OrganizationUserResetPasswordEnrollmentRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock function parameters
|
||||
credentials = {
|
||||
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey,
|
||||
newServerMasterKeyHash: "newServerMasterKeyHash",
|
||||
newLocalMasterKeyHash: "newLocalMasterKeyHash",
|
||||
newPasswordHint: "newPasswordHint",
|
||||
kdfConfig: DEFAULT_KDF_CONFIG,
|
||||
orgSsoIdentifier: "orgSsoIdentifier",
|
||||
orgId: "orgId",
|
||||
resetPasswordAutoEnroll: false,
|
||||
};
|
||||
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
|
||||
|
||||
// Mock other function data
|
||||
existingUserPublicKey = Utils.fromB64ToArray("existingUserPublicKey") as UserPublicKey;
|
||||
existingUserPrivateKey = Utils.fromB64ToArray("existingUserPrivateKey") as UserPrivateKey;
|
||||
userKeyEncryptedPrivateKey = new EncString("userKeyEncryptedPrivateKey");
|
||||
|
||||
keyPair = ["publicKey", new EncString("privateKey")];
|
||||
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
|
||||
|
||||
organizationKeys = {
|
||||
privateKey: "orgPrivateKey",
|
||||
publicKey: "orgPublicKey",
|
||||
} as OrganizationKeysResponse;
|
||||
orgPublicKeyEncryptedUserKey = new EncString("orgPublicKeyEncryptedUserKey");
|
||||
|
||||
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
|
||||
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
|
||||
|
||||
setPasswordRequest = new SetPasswordRequest(
|
||||
credentials.newServerMasterKeyHash,
|
||||
masterKeyEncryptedUserKey[1].encryptedString,
|
||||
credentials.newPasswordHint,
|
||||
credentials.orgSsoIdentifier,
|
||||
keysRequest,
|
||||
credentials.kdfConfig.kdfType,
|
||||
credentials.kdfConfig.iterations,
|
||||
);
|
||||
|
||||
enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
enrollmentRequest.masterPasswordHash = credentials.newServerMasterKeyHash;
|
||||
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
|
||||
});
|
||||
|
||||
interface MockConfig {
|
||||
userType: SetInitialPasswordUserType;
|
||||
userHasUserKey: boolean;
|
||||
userHasLocalKeyPair: boolean;
|
||||
resetPasswordAutoEnroll: boolean;
|
||||
}
|
||||
|
||||
const defaultMockConfig: MockConfig = {
|
||||
userType: SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER,
|
||||
userHasUserKey: true,
|
||||
userHasLocalKeyPair: false,
|
||||
resetPasswordAutoEnroll: false,
|
||||
};
|
||||
|
||||
function setupMocks(config: MockConfig = defaultMockConfig) {
|
||||
// Mock makeMasterKeyEncryptedUserKey() values
|
||||
if (config.userHasUserKey) {
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey);
|
||||
} else {
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
keyService.makeUserKey.mockResolvedValue(masterKeyEncryptedUserKey);
|
||||
}
|
||||
|
||||
// Mock keyPair values
|
||||
if (config.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
|
||||
if (config.userHasLocalKeyPair) {
|
||||
keyService.userPrivateKey$.mockReturnValue(of(existingUserPrivateKey));
|
||||
keyService.userPublicKey$.mockReturnValue(of(existingUserPublicKey));
|
||||
encryptService.wrapDecapsulationKey.mockResolvedValue(userKeyEncryptedPrivateKey);
|
||||
} else {
|
||||
keyService.userPrivateKey$.mockReturnValue(of(null));
|
||||
keyService.userPublicKey$.mockReturnValue(of(null));
|
||||
keyService.makeKeyPair.mockResolvedValue(keyPair);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock handleResetPasswordAutoEnroll() values
|
||||
if (config.resetPasswordAutoEnroll) {
|
||||
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey);
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
}
|
||||
}
|
||||
|
||||
describe("general error handling", () => {
|
||||
[
|
||||
"newMasterKey",
|
||||
"newServerMasterKeyHash",
|
||||
"newLocalMasterKeyHash",
|
||||
"newPasswordHint",
|
||||
"kdfConfig",
|
||||
"orgSsoIdentifier",
|
||||
"orgId",
|
||||
"resetPasswordAutoEnroll",
|
||||
].forEach((key) => {
|
||||
it(`should throw if ${key} is not provided on the SetInitialPasswordCredentials object`, async () => {
|
||||
// Arrange
|
||||
const invalidCredentials: SetInitialPasswordCredentials = {
|
||||
...credentials,
|
||||
[key]: null,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPassword(invalidCredentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`);
|
||||
});
|
||||
});
|
||||
|
||||
["userId", "userType"].forEach((param) => {
|
||||
it(`should throw if ${param} was not passed in`, async () => {
|
||||
// Arrange & Act
|
||||
const promise = sut.setInitialPassword(
|
||||
credentials,
|
||||
param === "userType" ? null : userType,
|
||||
param === "userId" ? null : userId,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(`${param} not found. Could not set password.`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER", () => {
|
||||
beforeEach(() => {
|
||||
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
|
||||
});
|
||||
|
||||
describe("given the user has an existing local key pair", () => {
|
||||
it("should NOT create a brand new key pair for the user", async () => {
|
||||
// Arrange
|
||||
setPasswordRequest.keys = {
|
||||
encryptedPrivateKey: userKeyEncryptedPrivateKey.encryptedString,
|
||||
publicKey: Utils.fromBufferToB64(existingUserPublicKey),
|
||||
};
|
||||
|
||||
setupMocks({ ...defaultMockConfig, userHasLocalKeyPair: true });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(userId);
|
||||
expect(keyService.userPublicKey$).toHaveBeenCalledWith(userId);
|
||||
expect(encryptService.wrapDecapsulationKey).toHaveBeenCalledWith(
|
||||
existingUserPrivateKey,
|
||||
masterKeyEncryptedUserKey[0],
|
||||
);
|
||||
expect(keyService.makeKeyPair).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the user has a userKey", () => {
|
||||
it("should successfully set an initial password", async () => {
|
||||
// Arrange
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the user does NOT have a userKey", () => {
|
||||
it("should successfully set an initial password", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userHasUserKey: false });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if a key pair is not found", async () => {
|
||||
// Arrange
|
||||
keyPair = null;
|
||||
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("keyPair not found. Could not set password.");
|
||||
expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw if an encrypted private key is not found", async () => {
|
||||
// Arrange
|
||||
keyPair[1].encryptedString = "" as EncryptedString;
|
||||
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"encrypted private key not found. Could not set password.",
|
||||
);
|
||||
expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("given the initial password has been successfully set", () => {
|
||||
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
|
||||
// Arrange
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.None,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update account decryption properties", async () => {
|
||||
// Arrange
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
|
||||
userDecryptionOptions,
|
||||
);
|
||||
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
|
||||
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(
|
||||
credentials.newMasterKey,
|
||||
userId,
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(masterKeyEncryptedUserKey[0], userId);
|
||||
});
|
||||
|
||||
it("should set the private key to state", async () => {
|
||||
// Arrange
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(keyService.setPrivateKey).toHaveBeenCalledWith(keyPair[1].encryptedString, userId);
|
||||
});
|
||||
|
||||
it("should set the local master key hash to state", async () => {
|
||||
// Arrange
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
|
||||
credentials.newLocalMasterKeyHash,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
describe("given resetPasswordAutoEnroll is true", () => {
|
||||
it(`should handle reset password (account recovery) auto enroll`, async () => {
|
||||
// Arrange
|
||||
credentials.resetPasswordAutoEnroll = true;
|
||||
|
||||
setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest);
|
||||
});
|
||||
|
||||
it("should throw if organization keys are not found", async () => {
|
||||
// Arrange
|
||||
credentials.resetPasswordAutoEnroll = true;
|
||||
organizationKeys = null;
|
||||
|
||||
setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true });
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Organization keys response is null. Could not handle reset password auto enroll.",
|
||||
);
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
["orgPublicKeyEncryptedUserKey", "orgPublicKeyEncryptedUserKey.encryptedString"].forEach(
|
||||
(property) => {
|
||||
it("should throw if orgPublicKeyEncryptedUserKey is not found", async () => {
|
||||
// Arrange
|
||||
credentials.resetPasswordAutoEnroll = true;
|
||||
|
||||
if (property === "orgPublicKeyEncryptedUserKey") {
|
||||
orgPublicKeyEncryptedUserKey = null;
|
||||
} else {
|
||||
orgPublicKeyEncryptedUserKey.encryptedString = "" as EncryptedString;
|
||||
}
|
||||
|
||||
setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true });
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
|
||||
);
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(
|
||||
setPasswordRequest,
|
||||
);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("given resetPasswordAutoEnroll is false", () => {
|
||||
it(`should NOT handle reset password (account recovery) auto enroll`, async () => {
|
||||
// Arrange
|
||||
credentials.resetPasswordAutoEnroll = false;
|
||||
|
||||
setupMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP", () => {
|
||||
beforeEach(() => {
|
||||
userType = SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP;
|
||||
setPasswordRequest.keys = null;
|
||||
});
|
||||
|
||||
it("should NOT generate a keyPair", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(keyService.userPrivateKey$).not.toHaveBeenCalled();
|
||||
expect(keyService.userPublicKey$).not.toHaveBeenCalled();
|
||||
expect(encryptService.wrapDecapsulationKey).not.toHaveBeenCalled();
|
||||
expect(keyService.makeKeyPair).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("given the user has a userKey", () => {
|
||||
it("should successfully set an initial password", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the user does NOT have a userKey", () => {
|
||||
it("should successfully set an initial password", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the initial password has been successfully set", () => {
|
||||
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.None,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update account decryption properties", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
|
||||
userDecryptionOptions,
|
||||
);
|
||||
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
|
||||
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(
|
||||
credentials.newMasterKey,
|
||||
userId,
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(masterKeyEncryptedUserKey[0], userId);
|
||||
});
|
||||
|
||||
it("should NOT set the private key to state", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set the local master key hash to state", async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
|
||||
credentials.newLocalMasterKeyHash,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
describe("given resetPasswordAutoEnroll is true", () => {
|
||||
it(`should handle reset password (account recovery) auto enroll`, async () => {
|
||||
// Arrange
|
||||
credentials.resetPasswordAutoEnroll = true;
|
||||
|
||||
setupMocks({ ...defaultMockConfig, userType, resetPasswordAutoEnroll: true });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given resetPasswordAutoEnroll is false", () => {
|
||||
it(`should NOT handle reset password (account recovery) auto enroll`, async () => {
|
||||
// Arrange
|
||||
setupMocks({ ...defaultMockConfig, userType });
|
||||
|
||||
// Act
|
||||
await sut.setInitialPassword(credentials, userType, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setInitialPasswordTdeOffboarding(...)", () => {
|
||||
// Mock function parameters
|
||||
let credentials: SetInitialPasswordTdeOffboardingCredentials;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock function parameters
|
||||
credentials = {
|
||||
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey,
|
||||
newServerMasterKeyHash: "newServerMasterKeyHash",
|
||||
newPasswordHint: "newPasswordHint",
|
||||
};
|
||||
});
|
||||
|
||||
function setupTdeOffboardingMocks() {
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey);
|
||||
}
|
||||
|
||||
it("should successfully set an initial password for the TDE offboarding user", async () => {
|
||||
// Arrange
|
||||
setupTdeOffboardingMocks();
|
||||
|
||||
const request = new UpdateTdeOffboardingPasswordRequest();
|
||||
request.key = masterKeyEncryptedUserKey[1].encryptedString;
|
||||
request.newMasterPasswordHash = credentials.newServerMasterKeyHash;
|
||||
request.masterPasswordHint = credentials.newPasswordHint;
|
||||
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
|
||||
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledWith(
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
describe("given the initial password has been successfully set", () => {
|
||||
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
|
||||
// Arrange
|
||||
setupTdeOffboardingMocks();
|
||||
|
||||
// Act
|
||||
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
|
||||
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.None,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("general error handling", () => {
|
||||
["newMasterKey", "newServerMasterKeyHash", "newPasswordHint"].forEach((key) => {
|
||||
it(`should throw if ${key} is not provided on the SetInitialPasswordTdeOffboardingCredentials object`, async () => {
|
||||
// Arrange
|
||||
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = {
|
||||
...credentials,
|
||||
[key]: null,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`);
|
||||
});
|
||||
});
|
||||
|
||||
it(`should throw if the userId was not passed in`, async () => {
|
||||
// Arrange
|
||||
userId = null;
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("userId not found. Could not set password.");
|
||||
});
|
||||
|
||||
it(`should throw if the userKey was not found`, async () => {
|
||||
// Arrange
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("userKey not found. Could not set password.");
|
||||
});
|
||||
|
||||
it(`should throw if a newMasterKeyEncryptedUserKey was not returned`, async () => {
|
||||
// Arrange
|
||||
masterKeyEncryptedUserKey[1].encryptedString = "" as EncryptedString;
|
||||
|
||||
setupTdeOffboardingMocks();
|
||||
|
||||
// Act
|
||||
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"newMasterKeyEncryptedUserKey not found. Could not set password.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
@if (initializing) {
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-3x"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</div>
|
||||
} @else {
|
||||
@if (userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER_UNTRUSTED_DEVICE) {
|
||||
<div class="tw-mt-4"></div>
|
||||
<bit-callout type="warning">
|
||||
{{ "loginOnTrustedDeviceOrAskAdminToAssignPassword" | i18n }}
|
||||
</bit-callout>
|
||||
<button type="button" bitButton block buttonType="secondary" (click)="logout()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
} @else {
|
||||
<bit-callout
|
||||
*ngIf="resetPasswordAutoEnroll"
|
||||
type="warning"
|
||||
title="{{ 'resetPasswordPolicyAutoEnroll' | i18n }}"
|
||||
>
|
||||
{{ "resetPasswordAutoEnrollInviteWarning" | i18n }}
|
||||
</bit-callout>
|
||||
|
||||
<auth-input-password
|
||||
[flow]="inputPasswordFlow"
|
||||
[email]="email"
|
||||
[userId]="userId"
|
||||
[loading]="submitting"
|
||||
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
|
||||
[primaryButtonText]="{
|
||||
key:
|
||||
userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER
|
||||
? 'setPassword'
|
||||
: 'createAccount',
|
||||
}"
|
||||
[secondaryButtonText]="{ key: 'logOut' }"
|
||||
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
|
||||
(onSecondaryButtonClick)="logout()"
|
||||
></auth-input-password>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
// import { NoAccess } from "libs/components/src/icon/icons";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
InputPasswordComponent,
|
||||
InputPasswordFlow,
|
||||
PasswordInputResult,
|
||||
} from "@bitwarden/auth/angular";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutService } from "@bitwarden/auth/common";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
ButtonModule,
|
||||
CalloutComponent,
|
||||
DialogService,
|
||||
ToastService,
|
||||
Icons,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import {
|
||||
SetInitialPasswordCredentials,
|
||||
SetInitialPasswordService,
|
||||
SetInitialPasswordTdeOffboardingCredentials,
|
||||
SetInitialPasswordUserType,
|
||||
} from "./set-initial-password.service.abstraction";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
templateUrl: "set-initial-password.component.html",
|
||||
imports: [ButtonModule, CalloutComponent, CommonModule, InputPasswordComponent, I18nPipe],
|
||||
})
|
||||
export class SetInitialPasswordComponent implements OnInit {
|
||||
protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser;
|
||||
|
||||
protected email?: string;
|
||||
protected forceSetPasswordReason?: ForceSetPasswordReason;
|
||||
protected initializing = true;
|
||||
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
|
||||
protected orgId?: string;
|
||||
protected orgSsoIdentifier?: string;
|
||||
protected resetPasswordAutoEnroll?: boolean;
|
||||
protected submitting = false;
|
||||
protected userId?: UserId;
|
||||
protected userType?: SetInitialPasswordUserType;
|
||||
protected SetInitialPasswordUserType = SetInitialPasswordUserType;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private logoutService: LogoutService,
|
||||
private logService: LogService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private messagingService: MessagingService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private policyService: PolicyService,
|
||||
private router: Router,
|
||||
private setInitialPasswordService: SetInitialPasswordService,
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
private syncService: SyncService,
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
this.userId = activeAccount?.id;
|
||||
this.email = activeAccount?.email;
|
||||
|
||||
await this.establishUserType();
|
||||
await this.getOrgInfo();
|
||||
|
||||
this.initializing = false;
|
||||
}
|
||||
|
||||
private async establishUserType() {
|
||||
if (!this.userId) {
|
||||
throw new Error("userId not found. Could not determine user type.");
|
||||
}
|
||||
|
||||
this.forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(this.userId),
|
||||
);
|
||||
|
||||
if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboardingUntrustedDevice) {
|
||||
this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER_UNTRUSTED_DEVICE;
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "unableToCompleteLogin" },
|
||||
pageIcon: Icons.NoAccess,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser) {
|
||||
this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "joinOrganization" },
|
||||
pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.forceSetPasswordReason ===
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
|
||||
) {
|
||||
this.userType = SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP;
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "setMasterPassword" },
|
||||
pageSubtitle: { key: "orgPermissionsUpdatedMustSetPassword" },
|
||||
});
|
||||
}
|
||||
|
||||
if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) {
|
||||
this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER;
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: { key: "setMasterPassword" },
|
||||
pageSubtitle: { key: "tdeDisabledMasterPasswordRequired" },
|
||||
});
|
||||
}
|
||||
|
||||
// If we somehow end up here without a reason, navigate to root
|
||||
if (this.forceSetPasswordReason === ForceSetPasswordReason.None) {
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrgInfo() {
|
||||
if (!this.userId) {
|
||||
throw new Error("userId not found. Could not handle query params.");
|
||||
}
|
||||
|
||||
if (this.userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER) {
|
||||
this.masterPasswordPolicyOptions =
|
||||
(await firstValueFrom(this.policyService.masterPasswordPolicyOptions$(this.userId))) ??
|
||||
null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
|
||||
|
||||
this.orgSsoIdentifier =
|
||||
qParams.identifier ??
|
||||
(await this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.userId));
|
||||
|
||||
if (this.orgSsoIdentifier != null) {
|
||||
try {
|
||||
const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus(
|
||||
this.orgSsoIdentifier,
|
||||
);
|
||||
this.orgId = autoEnrollStatus.id;
|
||||
this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled;
|
||||
this.masterPasswordPolicyOptions =
|
||||
await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(this.orgId);
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
|
||||
this.submitting = true;
|
||||
|
||||
switch (this.userType) {
|
||||
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
|
||||
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
|
||||
await this.setInitialPassword(passwordInputResult);
|
||||
break;
|
||||
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
|
||||
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
|
||||
break;
|
||||
default:
|
||||
this.logService.error(
|
||||
`Unexpected user type: ${this.userType}. Could not set initial password.`,
|
||||
);
|
||||
this.validationService.showError("Unexpected user type. Could not set initial password.");
|
||||
}
|
||||
}
|
||||
|
||||
private async setInitialPassword(passwordInputResult: PasswordInputResult) {
|
||||
const ctx = "Could not set initial password.";
|
||||
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
|
||||
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
|
||||
assertTruthy(passwordInputResult.newLocalMasterKeyHash, "newLocalMasterKeyHash", ctx);
|
||||
assertTruthy(passwordInputResult.kdfConfig, "kdfConfig", ctx);
|
||||
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
|
||||
assertTruthy(this.orgId, "orgId", ctx);
|
||||
assertTruthy(this.userType, "userType", ctx);
|
||||
assertTruthy(this.userId, "userId", ctx);
|
||||
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
|
||||
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
|
||||
|
||||
try {
|
||||
const credentials: SetInitialPasswordCredentials = {
|
||||
newMasterKey: passwordInputResult.newMasterKey,
|
||||
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
|
||||
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
|
||||
newPasswordHint: passwordInputResult.newPasswordHint,
|
||||
kdfConfig: passwordInputResult.kdfConfig,
|
||||
orgSsoIdentifier: this.orgSsoIdentifier,
|
||||
orgId: this.orgId,
|
||||
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
|
||||
};
|
||||
|
||||
await this.setInitialPasswordService.setInitialPassword(
|
||||
credentials,
|
||||
this.userType,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
this.showSuccessToastByUserType();
|
||||
|
||||
this.submitting = false;
|
||||
await this.router.navigate(["vault"]);
|
||||
} catch (e) {
|
||||
this.logService.error("Error setting initial password", e);
|
||||
this.validationService.showError(e);
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) {
|
||||
const ctx = "Could not set initial password.";
|
||||
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
|
||||
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
|
||||
assertTruthy(this.userId, "userId", ctx);
|
||||
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
|
||||
|
||||
try {
|
||||
const credentials: SetInitialPasswordTdeOffboardingCredentials = {
|
||||
newMasterKey: passwordInputResult.newMasterKey,
|
||||
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
|
||||
newPasswordHint: passwordInputResult.newPasswordHint,
|
||||
};
|
||||
|
||||
await this.setInitialPasswordService.setInitialPasswordTdeOffboarding(
|
||||
credentials,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
this.showSuccessToastByUserType();
|
||||
|
||||
await this.logoutService.logout(this.userId);
|
||||
// navigate to root so redirect guard can properly route next active user or null user to correct page
|
||||
await this.router.navigate(["/"]);
|
||||
} catch (e) {
|
||||
this.logService.error("Error setting initial password during TDE offboarding", e);
|
||||
this.validationService.showError(e);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private showSuccessToastByUserType() {
|
||||
if (this.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("accountSuccessfullyCreated"),
|
||||
});
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("inviteAccepted"),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("masterPasswordSuccessfullySet"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async logout() {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "logOut" },
|
||||
content: { key: "logOutConfirmation" },
|
||||
acceptButtonText: { key: "logOut" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
export const _SetInitialPasswordUserType = {
|
||||
/**
|
||||
* A user being "just-in-time" (JIT) provisioned into a master-password-encryption org
|
||||
*/
|
||||
JIT_PROVISIONED_MP_ORG_USER: "jit_provisioned_mp_org_user",
|
||||
|
||||
/**
|
||||
* Could be one of two scenarios:
|
||||
* 1. A user being "just-in-time" (JIT) provisioned into a trusted-device-encryption org
|
||||
* with the reset password permission granted ("manage account recovery"), which requires
|
||||
* that the user sets a master password
|
||||
* 2. An user in a trusted-device-encryption org whose permissions were upgraded to include
|
||||
* the reset password permission ("manage account recovery"), which requires that the user
|
||||
* sets a master password
|
||||
*/
|
||||
TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
|
||||
"tde_org_user_reset_password_permission_requires_mp",
|
||||
|
||||
/**
|
||||
* A user in an org that offboarded from trusted device encryption and is now a
|
||||
* master-password-encryption org. User is on a trusted device.
|
||||
*/
|
||||
OFFBOARDED_TDE_ORG_USER: "offboarded_tde_org_user",
|
||||
|
||||
/**
|
||||
* A user in an org that offboarded from trusted device encryption and is now a
|
||||
* master-password-encryption org. User is on an untrusted device.
|
||||
*/
|
||||
OFFBOARDED_TDE_ORG_USER_UNTRUSTED_DEVICE: "offboarded_tde_org_user_untrusted_device",
|
||||
} as const;
|
||||
|
||||
type _SetInitialPasswordUserType = typeof _SetInitialPasswordUserType;
|
||||
|
||||
export type SetInitialPasswordUserType =
|
||||
_SetInitialPasswordUserType[keyof _SetInitialPasswordUserType];
|
||||
export const SetInitialPasswordUserType: Readonly<{
|
||||
[K in keyof typeof _SetInitialPasswordUserType]: SetInitialPasswordUserType;
|
||||
}> = Object.freeze(_SetInitialPasswordUserType);
|
||||
|
||||
export interface SetInitialPasswordCredentials {
|
||||
newMasterKey: MasterKey;
|
||||
newServerMasterKeyHash: string;
|
||||
newLocalMasterKeyHash: string;
|
||||
newPasswordHint: string;
|
||||
kdfConfig: KdfConfig;
|
||||
orgSsoIdentifier: string;
|
||||
orgId: string;
|
||||
resetPasswordAutoEnroll: boolean;
|
||||
}
|
||||
|
||||
export interface SetInitialPasswordTdeOffboardingCredentials {
|
||||
newMasterKey: MasterKey;
|
||||
newServerMasterKeyHash: string;
|
||||
newPasswordHint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles setting an initial password for an existing authed user.
|
||||
*
|
||||
* To see the different scenarios where an existing authed user needs to set an
|
||||
* initial password, see {@link SetInitialPasswordUserType}
|
||||
*/
|
||||
export abstract class SetInitialPasswordService {
|
||||
/**
|
||||
* Sets an initial password for an existing authed user who is either:
|
||||
* - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER}
|
||||
* - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP}
|
||||
*
|
||||
* @param credentials An object of the credentials needed to set the initial password
|
||||
* @throws If any property on the `credentials` object is null or undefined, or if a
|
||||
* masterKeyEncryptedUserKey or newKeyPair could not be created.
|
||||
*/
|
||||
abstract setInitialPassword: (
|
||||
credentials: SetInitialPasswordCredentials,
|
||||
userType: SetInitialPasswordUserType,
|
||||
userId: UserId,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets an initial password for a user who logs in after their org offboarded from
|
||||
* trusted device encryption and is now a master-password-encryption org:
|
||||
* - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER}
|
||||
*
|
||||
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
|
||||
* @param userId the account `userId`
|
||||
*/
|
||||
abstract setInitialPasswordTdeOffboarding: (
|
||||
credentials: SetInitialPasswordTdeOffboardingCredentials,
|
||||
userId: UserId,
|
||||
) => Promise<void>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "country" | i18n }}</bit-label>
|
||||
<bit-select formControlName="country">
|
||||
<bit-select formControlName="country" data-testid="country">
|
||||
<bit-option
|
||||
*ngFor="let country of countries"
|
||||
[value]="country.value"
|
||||
@@ -16,38 +16,68 @@
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="postalCode" autocomplete="postal-code" />
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="postalCode"
|
||||
autocomplete="postal-code"
|
||||
data-testid="postal-code"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<ng-container *ngIf="isTaxSupported">
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "address1" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="line1" autocomplete="address-line1" />
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="line1"
|
||||
autocomplete="address-line1"
|
||||
data-testid="address-line1"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "address2" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="line2" autocomplete="address-line2" />
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="line2"
|
||||
autocomplete="address-line2"
|
||||
data-testid="address-line2"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "cityTown" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="city" autocomplete="address-level2" />
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="city"
|
||||
autocomplete="address-level2"
|
||||
data-testid="city"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="state" autocomplete="address-level1" />
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
formControlName="state"
|
||||
autocomplete="address-level1"
|
||||
data-testid="state"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6" *ngIf="showTaxIdField">
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="taxId" />
|
||||
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { 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 { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { Checkable, isChecked } from "@bitwarden/common/types/checkable";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
@Directive()
|
||||
export class ShareComponent implements OnInit, OnDestroy {
|
||||
@Input() cipherId: string;
|
||||
@Input() organizationId: string;
|
||||
@Output() onSharedCipher = new EventEmitter();
|
||||
|
||||
formPromise: Promise<void>;
|
||||
cipher: CipherView;
|
||||
collections: Checkable<CollectionView>[] = [];
|
||||
organizations$: Observable<Organization[]>;
|
||||
|
||||
protected writeableCollections: Checkable<CollectionView>[] = [];
|
||||
|
||||
private _destroy = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
protected collectionService: CollectionService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected i18nService: I18nService,
|
||||
protected cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
protected organizationService: OrganizationService,
|
||||
protected accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._destroy.next();
|
||||
this._destroy.complete();
|
||||
}
|
||||
|
||||
async load() {
|
||||
const allCollections = await this.collectionService.getAllDecrypted();
|
||||
this.writeableCollections = allCollections.map((c) => c).filter((c) => !c.readOnly);
|
||||
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
|
||||
this.organizations$ = this.organizationService.memberOrganizations$(userId).pipe(
|
||||
map((orgs) => {
|
||||
return orgs
|
||||
.filter((o) => o.enabled && o.status === OrganizationUserStatusType.Confirmed)
|
||||
.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
}),
|
||||
);
|
||||
|
||||
this.organizations$.pipe(takeUntil(this._destroy)).subscribe((orgs) => {
|
||||
if (this.organizationId == null && orgs.length > 0) {
|
||||
this.organizationId = orgs[0].id;
|
||||
this.filterCollections();
|
||||
}
|
||||
});
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
|
||||
this.cipher = await this.cipherService.decrypt(cipherDomain, activeUserId);
|
||||
}
|
||||
|
||||
filterCollections() {
|
||||
this.writeableCollections.forEach((c) => (c.checked = false));
|
||||
if (this.organizationId == null || this.writeableCollections.length === 0) {
|
||||
this.collections = [];
|
||||
} else {
|
||||
this.collections = this.writeableCollections.filter(
|
||||
(c) => c.organizationId === this.organizationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async submit(): Promise<boolean> {
|
||||
const selectedCollectionIds = this.collections.filter(isChecked).map((c) => c.id);
|
||||
if (selectedCollectionIds.length === 0) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
this.i18nService.t("selectOneCollection"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
|
||||
const cipherView = await this.cipherService.decrypt(cipherDomain, activeUserId);
|
||||
const orgs = await firstValueFrom(this.organizations$);
|
||||
const orgName =
|
||||
orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization");
|
||||
|
||||
try {
|
||||
this.formPromise = this.cipherService
|
||||
.shareWithServer(cipherView, this.organizationId, selectedCollectionIds, activeUserId)
|
||||
.then(async () => {
|
||||
this.onSharedCipher.emit();
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("movedItemToOrg", cipherView.name, orgName),
|
||||
);
|
||||
});
|
||||
await this.formPromise;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get canSave() {
|
||||
if (this.collections != null) {
|
||||
for (let i = 0; i < this.collections.length; i++) {
|
||||
if (this.collections[i].checked) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,6 @@ import { UserTypePipe } from "./pipes/user-type.pipe";
|
||||
import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe";
|
||||
import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe";
|
||||
import { I18nPipe } from "./platform/pipes/i18n.pipe";
|
||||
import { PasswordStrengthComponent } from "./tools/password-strength/password-strength.component";
|
||||
import { IconComponent } from "./vault/components/icon.component";
|
||||
|
||||
@NgModule({
|
||||
@@ -108,7 +107,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
TrueFalseValueDirective,
|
||||
LaunchClickDirective,
|
||||
UserNamePipe,
|
||||
PasswordStrengthComponent,
|
||||
UserTypePipe,
|
||||
IfFeatureDirective,
|
||||
FingerprintPipe,
|
||||
@@ -143,7 +141,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
CopyClickDirective,
|
||||
LaunchClickDirective,
|
||||
UserNamePipe,
|
||||
PasswordStrengthComponent,
|
||||
UserTypePipe,
|
||||
IfFeatureDirective,
|
||||
FingerprintPipe,
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
DefaultOrganizationUserApiService,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
ChangePasswordService,
|
||||
DefaultChangePasswordService,
|
||||
} from "@bitwarden/angular/auth/password-management/change-password";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
@@ -18,19 +22,13 @@ import {
|
||||
DefaultLoginComponentService,
|
||||
DefaultLoginDecryptionOptionsService,
|
||||
DefaultRegistrationFinishService,
|
||||
DefaultSetPasswordJitService,
|
||||
DefaultTwoFactorAuthComponentService,
|
||||
DefaultTwoFactorAuthEmailComponentService,
|
||||
DefaultTwoFactorAuthWebAuthnComponentService,
|
||||
LoginComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
||||
SetPasswordJitService,
|
||||
TwoFactorAuthComponentService,
|
||||
TwoFactorAuthEmailComponentService,
|
||||
TwoFactorAuthWebAuthnComponentService,
|
||||
ChangePasswordService,
|
||||
DefaultChangePasswordService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -59,7 +57,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import {
|
||||
InternalOrganizationServiceAbstraction,
|
||||
@@ -116,6 +113,8 @@ import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
|
||||
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
|
||||
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation";
|
||||
import { DefaultOrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/default-organization-invite.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
|
||||
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/services/token.service";
|
||||
@@ -168,6 +167,10 @@ import {
|
||||
MasterPasswordServiceAbstraction,
|
||||
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||
import {
|
||||
SendPasswordService,
|
||||
DefaultSendPasswordService,
|
||||
} from "@bitwarden/common/key-management/sends";
|
||||
import {
|
||||
DefaultVaultTimeoutService,
|
||||
DefaultVaultTimeoutSettingsService,
|
||||
@@ -257,7 +260,6 @@ import { ApiService } from "@bitwarden/common/services/api.service";
|
||||
import { AuditService } from "@bitwarden/common/services/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||
import { SearchService } from "@bitwarden/common/services/search.service";
|
||||
import {
|
||||
PasswordStrengthService,
|
||||
PasswordStrengthServiceAbstraction,
|
||||
@@ -279,6 +281,7 @@ import {
|
||||
FolderService as FolderServiceAbstraction,
|
||||
InternalFolderService,
|
||||
} from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import {
|
||||
@@ -295,6 +298,7 @@ import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-u
|
||||
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/services/search.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
|
||||
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
@@ -339,6 +343,8 @@ import {
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
|
||||
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||
@@ -496,6 +502,7 @@ const safeProviders: SafeProvider[] = [
|
||||
VaultTimeoutSettingsService,
|
||||
KdfConfigService,
|
||||
TaskSchedulerService,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1179,7 +1186,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: DevicesServiceAbstraction,
|
||||
useClass: DevicesServiceImplementation,
|
||||
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
|
||||
deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestApiServiceAbstraction,
|
||||
@@ -1314,7 +1321,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: AutofillSettingsServiceAbstraction,
|
||||
useClass: AutofillSettingsService,
|
||||
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
|
||||
deps: [StateProvider, PolicyServiceAbstraction, AccountService, RestrictedItemTypesService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BadgeSettingsServiceAbstraction,
|
||||
@@ -1329,7 +1336,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: VaultSettingsServiceAbstraction,
|
||||
useClass: VaultSettingsService,
|
||||
deps: [StateProvider],
|
||||
deps: [StateProvider, RestrictedItemTypesService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MigrationRunner,
|
||||
@@ -1404,15 +1411,20 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SetPasswordJitService,
|
||||
useClass: DefaultSetPasswordJitService,
|
||||
provide: OrganizationInviteService,
|
||||
useClass: DefaultOrganizationInviteService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SetInitialPasswordService,
|
||||
useClass: DefaultSetInitialPasswordService,
|
||||
deps: [
|
||||
ApiServiceAbstraction,
|
||||
MasterPasswordApiServiceAbstraction,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
I18nServiceAbstraction,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
MasterPasswordApiServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
OrganizationApiServiceAbstraction,
|
||||
OrganizationUserApiService,
|
||||
@@ -1444,11 +1456,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultTwoFactorAuthWebAuthnComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorAuthEmailComponentService,
|
||||
useClass: DefaultTwoFactorAuthEmailComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ViewCacheService,
|
||||
useExisting: NoopViewCacheService,
|
||||
@@ -1482,6 +1489,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultCipherAuthorizationService,
|
||||
deps: [CollectionService, OrganizationServiceAbstraction, AccountServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SendPasswordService,
|
||||
useClass: DefaultSendPasswordService,
|
||||
deps: [CryptoFunctionServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginApprovalComponentServiceAbstraction,
|
||||
useClass: DefaultLoginApprovalComponentService,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<div class="progress">
|
||||
<div
|
||||
class="progress-bar {{ color }}"
|
||||
role="progressbar"
|
||||
[ngStyle]="{ width: scoreWidth + '%' }"
|
||||
attr.aria-valuenow="{{ scoreWidth }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<ng-container *ngIf="showText && text">
|
||||
{{ text }}
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,118 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, EventEmitter, Input, OnChanges, Output } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
|
||||
export interface PasswordColorText {
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 2024: Use new PasswordStrengthV2Component instead
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-password-strength",
|
||||
templateUrl: "password-strength.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class PasswordStrengthComponent implements OnChanges {
|
||||
@Input() showText = false;
|
||||
@Input() email: string;
|
||||
@Input() name: string;
|
||||
@Input() set password(value: string) {
|
||||
this.updatePasswordStrength(value);
|
||||
}
|
||||
@Output() passwordStrengthResult = new EventEmitter<any>();
|
||||
@Output() passwordScoreColor = new EventEmitter<PasswordColorText>();
|
||||
|
||||
masterPasswordScore: number;
|
||||
scoreWidth = 0;
|
||||
color = "bg-danger";
|
||||
text: string;
|
||||
|
||||
private masterPasswordStrengthTimeout: any;
|
||||
|
||||
//used by desktop and browser to display strength text color
|
||||
get masterPasswordScoreColor() {
|
||||
switch (this.masterPasswordScore) {
|
||||
case 4:
|
||||
return "success";
|
||||
case 3:
|
||||
return "primary";
|
||||
case 2:
|
||||
return "warning";
|
||||
default:
|
||||
return "danger";
|
||||
}
|
||||
}
|
||||
|
||||
//used by desktop and browser to display strength text
|
||||
get masterPasswordScoreText() {
|
||||
switch (this.masterPasswordScore) {
|
||||
case 4:
|
||||
return this.i18nService.t("strong");
|
||||
case 3:
|
||||
return this.i18nService.t("good");
|
||||
case 2:
|
||||
return this.i18nService.t("weak");
|
||||
default:
|
||||
return this.masterPasswordScore != null ? this.i18nService.t("weak") : null;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||
) {}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.masterPasswordStrengthTimeout = setTimeout(() => {
|
||||
this.scoreWidth = this.masterPasswordScore == null ? 0 : (this.masterPasswordScore + 1) * 20;
|
||||
|
||||
switch (this.masterPasswordScore) {
|
||||
case 4:
|
||||
this.color = "bg-success";
|
||||
this.text = this.i18nService.t("strong");
|
||||
break;
|
||||
case 3:
|
||||
this.color = "bg-primary";
|
||||
this.text = this.i18nService.t("good");
|
||||
break;
|
||||
case 2:
|
||||
this.color = "bg-warning";
|
||||
this.text = this.i18nService.t("weak");
|
||||
break;
|
||||
default:
|
||||
this.color = "bg-danger";
|
||||
this.text = this.masterPasswordScore != null ? this.i18nService.t("weak") : null;
|
||||
break;
|
||||
}
|
||||
|
||||
this.setPasswordScoreText(this.color, this.text);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
updatePasswordStrength(password: string) {
|
||||
const masterPassword = password;
|
||||
|
||||
if (this.masterPasswordStrengthTimeout != null) {
|
||||
clearTimeout(this.masterPasswordStrengthTimeout);
|
||||
}
|
||||
|
||||
const strengthResult = this.passwordStrengthService.getPasswordStrength(
|
||||
masterPassword,
|
||||
this.email,
|
||||
this.name?.trim().toLowerCase().split(" "),
|
||||
);
|
||||
this.passwordStrengthResult.emit(strengthResult);
|
||||
this.masterPasswordScore = strengthResult == null ? null : strengthResult.score;
|
||||
}
|
||||
|
||||
setPasswordScoreText(color: string, text: string) {
|
||||
color = color.slice(3);
|
||||
this.passwordScoreColor.emit({ color: color, text: text });
|
||||
}
|
||||
}
|
||||
@@ -148,14 +148,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
return null;
|
||||
}
|
||||
|
||||
get isSafari() {
|
||||
return this.platformUtilsService.isSafari();
|
||||
}
|
||||
|
||||
get isDateTimeLocalSupported(): boolean {
|
||||
return !(this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari());
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
combineLatest,
|
||||
} from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -25,6 +24,7 @@ import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@Directive()
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { DynamicTreeNode } from "../vault-filter/models/dynamic-tree-node.model";
|
||||
@@ -14,11 +13,14 @@ import { DynamicTreeNode } from "../vault-filter/models/dynamic-tree-node.model"
|
||||
* @deprecated August 30 2022: Use new VaultFilterService with observables
|
||||
*/
|
||||
export abstract class DeprecatedVaultFilterService {
|
||||
buildOrganizations: () => Promise<Organization[]>;
|
||||
buildNestedFolders: (organizationId?: string) => Observable<DynamicTreeNode<FolderView>>;
|
||||
buildCollections: (organizationId?: string) => Promise<DynamicTreeNode<CollectionView>>;
|
||||
buildCollapsedFilterNodes: () => Promise<Set<string>>;
|
||||
storeCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
|
||||
checkForSingleOrganizationPolicy: () => Promise<boolean>;
|
||||
checkForOrganizationDataOwnershipPolicy: () => Promise<boolean>;
|
||||
abstract buildOrganizations(): Promise<Organization[]>;
|
||||
abstract buildNestedFolders(organizationId?: string): Observable<DynamicTreeNode<FolderView>>;
|
||||
abstract buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>>;
|
||||
abstract buildCollapsedFilterNodes(userId: UserId): Promise<Set<string>>;
|
||||
abstract storeCollapsedFilterNodes(
|
||||
collapsedFilterNodes: Set<string>,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
abstract checkForSingleOrganizationPolicy(): Promise<boolean>;
|
||||
abstract checkForOrganizationDataOwnershipPolicy(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
|
||||
import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
|
||||
@Directive()
|
||||
export class AddEditCustomFieldsComponent implements OnChanges {
|
||||
@Input() cipher: CipherView;
|
||||
@Input() thisCipherType: CipherType;
|
||||
@Input() editMode: boolean;
|
||||
|
||||
addFieldType: FieldType = FieldType.Text;
|
||||
addFieldTypeOptions: any[];
|
||||
addFieldLinkedTypeOption: any;
|
||||
linkedFieldOptions: any[] = [];
|
||||
|
||||
cipherType = CipherType;
|
||||
fieldType = FieldType;
|
||||
eventType = EventType;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
) {
|
||||
this.addFieldTypeOptions = [
|
||||
{ name: i18nService.t("cfTypeText"), value: FieldType.Text },
|
||||
{ name: i18nService.t("cfTypeHidden"), value: FieldType.Hidden },
|
||||
{ name: i18nService.t("cfTypeBoolean"), value: FieldType.Boolean },
|
||||
];
|
||||
this.addFieldLinkedTypeOption = {
|
||||
name: this.i18nService.t("cfTypeLinked"),
|
||||
value: FieldType.Linked,
|
||||
};
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.thisCipherType != null) {
|
||||
this.setLinkedFieldOptions();
|
||||
|
||||
if (!changes.thisCipherType.firstChange) {
|
||||
this.resetCipherLinkedFields();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addField() {
|
||||
if (this.cipher.fields == null) {
|
||||
this.cipher.fields = [];
|
||||
}
|
||||
|
||||
const f = new FieldView();
|
||||
f.type = this.addFieldType;
|
||||
f.newField = true;
|
||||
|
||||
if (f.type === FieldType.Linked) {
|
||||
f.linkedId = this.linkedFieldOptions[0].value;
|
||||
}
|
||||
|
||||
this.cipher.fields.push(f);
|
||||
}
|
||||
|
||||
removeField(field: FieldView) {
|
||||
const i = this.cipher.fields.indexOf(field);
|
||||
if (i > -1) {
|
||||
this.cipher.fields.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFieldValue(field: FieldView) {
|
||||
const f = field as any;
|
||||
f.showValue = !f.showValue;
|
||||
if (this.editMode && f.showValue) {
|
||||
// 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.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledHiddenFieldVisible,
|
||||
this.cipher.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
trackByFunction(index: number, item: any) {
|
||||
return index;
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<string[]>) {
|
||||
moveItemInArray(this.cipher.fields, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
|
||||
private setLinkedFieldOptions() {
|
||||
if (this.cipher.linkedFieldOptions == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options: any = [];
|
||||
this.cipher.linkedFieldOptions.forEach((linkedFieldOption, id) =>
|
||||
options.push({ name: this.i18nService.t(linkedFieldOption.i18nKey), value: id }),
|
||||
);
|
||||
this.linkedFieldOptions = options.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
}
|
||||
|
||||
private resetCipherLinkedFields() {
|
||||
if (this.cipher.fields == null || this.cipher.fields.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete any Linked custom fields if the item type does not support them
|
||||
if (this.cipher.linkedFieldOptions == null) {
|
||||
this.cipher.fields = this.cipher.fields.filter((f) => f.type !== FieldType.Linked);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipher.fields
|
||||
.filter((f) => f.type === FieldType.Linked)
|
||||
.forEach((f) => (f.linkedId = this.linkedFieldOptions[0].value));
|
||||
}
|
||||
}
|
||||
@@ -1,855 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { concatMap, firstValueFrom, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
CipherService,
|
||||
EncryptionContext,
|
||||
} from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
|
||||
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { generate_ssh_key } from "@bitwarden/sdk-internal";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
|
||||
@Directive()
|
||||
export class AddEditComponent implements OnInit, OnDestroy {
|
||||
@Input() cloneMode = false;
|
||||
@Input() folderId: string = null;
|
||||
@Input() cipherId: string;
|
||||
@Input() type: CipherType;
|
||||
@Input() collectionIds: string[];
|
||||
@Input() organizationId: string = null;
|
||||
@Input() collectionId: string = null;
|
||||
@Output() onSavedCipher = new EventEmitter<CipherView>();
|
||||
@Output() onDeletedCipher = new EventEmitter<CipherView>();
|
||||
@Output() onRestoredCipher = new EventEmitter<CipherView>();
|
||||
@Output() onCancelled = new EventEmitter<CipherView>();
|
||||
@Output() onEditAttachments = new EventEmitter<CipherView>();
|
||||
@Output() onShareCipher = new EventEmitter<CipherView>();
|
||||
@Output() onEditCollections = new EventEmitter<CipherView>();
|
||||
@Output() onGeneratePassword = new EventEmitter();
|
||||
@Output() onGenerateUsername = new EventEmitter();
|
||||
|
||||
canDeleteCipher$: Observable<boolean>;
|
||||
|
||||
editMode = false;
|
||||
cipher: CipherView;
|
||||
folders$: Observable<FolderView[]>;
|
||||
collections: CollectionView[] = [];
|
||||
title: string;
|
||||
formPromise: Promise<any>;
|
||||
deletePromise: Promise<any>;
|
||||
restorePromise: Promise<any>;
|
||||
checkPasswordPromise: Promise<number>;
|
||||
showPassword = false;
|
||||
showPrivateKey = false;
|
||||
showTotpSeed = false;
|
||||
showCardNumber = false;
|
||||
showCardCode = false;
|
||||
cipherType = CipherType;
|
||||
cardBrandOptions: any[];
|
||||
cardExpMonthOptions: any[];
|
||||
identityTitleOptions: any[];
|
||||
uriMatchOptions: any[];
|
||||
ownershipOptions: any[] = [];
|
||||
autofillOnPageLoadOptions: any[];
|
||||
currentDate = new Date();
|
||||
allowPersonal = true;
|
||||
reprompt = false;
|
||||
canUseReprompt = true;
|
||||
organization: Organization;
|
||||
/**
|
||||
* Flag to determine if the action is being performed from the admin console.
|
||||
*/
|
||||
isAdminConsoleAction: boolean = false;
|
||||
|
||||
protected componentName = "";
|
||||
protected destroy$ = new Subject<void>();
|
||||
protected writeableCollections: CollectionView[];
|
||||
private organizationDataOwnershipAppliesToUser: boolean;
|
||||
private previousCipherId: string;
|
||||
|
||||
get fido2CredentialCreationDateValue(): string {
|
||||
const dateCreated = this.i18nService.t("dateCreated");
|
||||
const creationDate = this.datePipe.transform(
|
||||
this.cipher?.login?.fido2Credentials?.[0]?.creationDate,
|
||||
"short",
|
||||
);
|
||||
return `${dateCreated} ${creationDate}`;
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected folderService: FolderService,
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected auditService: AuditService,
|
||||
protected accountService: AccountService,
|
||||
protected collectionService: CollectionService,
|
||||
protected messagingService: MessagingService,
|
||||
protected eventCollectionService: EventCollectionService,
|
||||
protected policyService: PolicyService,
|
||||
protected logService: LogService,
|
||||
protected passwordRepromptService: PasswordRepromptService,
|
||||
private organizationService: OrganizationService,
|
||||
protected dialogService: DialogService,
|
||||
protected win: Window,
|
||||
protected datePipe: DatePipe,
|
||||
protected configService: ConfigService,
|
||||
protected cipherAuthorizationService: CipherAuthorizationService,
|
||||
protected toastService: ToastService,
|
||||
protected sdkService: SdkService,
|
||||
private sshImportPromptService: SshImportPromptService,
|
||||
) {
|
||||
this.cardBrandOptions = [
|
||||
{ name: "-- " + i18nService.t("select") + " --", value: null },
|
||||
{ name: "Visa", value: "Visa" },
|
||||
{ name: "Mastercard", value: "Mastercard" },
|
||||
{ name: "American Express", value: "Amex" },
|
||||
{ name: "Discover", value: "Discover" },
|
||||
{ name: "Diners Club", value: "Diners Club" },
|
||||
{ name: "JCB", value: "JCB" },
|
||||
{ name: "Maestro", value: "Maestro" },
|
||||
{ name: "UnionPay", value: "UnionPay" },
|
||||
{ name: "RuPay", value: "RuPay" },
|
||||
{ name: i18nService.t("other"), value: "Other" },
|
||||
];
|
||||
this.cardExpMonthOptions = [
|
||||
{ name: "-- " + i18nService.t("select") + " --", value: null },
|
||||
{ name: "01 - " + i18nService.t("january"), value: "1" },
|
||||
{ name: "02 - " + i18nService.t("february"), value: "2" },
|
||||
{ name: "03 - " + i18nService.t("march"), value: "3" },
|
||||
{ name: "04 - " + i18nService.t("april"), value: "4" },
|
||||
{ name: "05 - " + i18nService.t("may"), value: "5" },
|
||||
{ name: "06 - " + i18nService.t("june"), value: "6" },
|
||||
{ name: "07 - " + i18nService.t("july"), value: "7" },
|
||||
{ name: "08 - " + i18nService.t("august"), value: "8" },
|
||||
{ name: "09 - " + i18nService.t("september"), value: "9" },
|
||||
{ name: "10 - " + i18nService.t("october"), value: "10" },
|
||||
{ name: "11 - " + i18nService.t("november"), value: "11" },
|
||||
{ name: "12 - " + i18nService.t("december"), value: "12" },
|
||||
];
|
||||
this.identityTitleOptions = [
|
||||
{ name: "-- " + i18nService.t("select") + " --", value: null },
|
||||
{ name: i18nService.t("mr"), value: i18nService.t("mr") },
|
||||
{ name: i18nService.t("mrs"), value: i18nService.t("mrs") },
|
||||
{ name: i18nService.t("ms"), value: i18nService.t("ms") },
|
||||
{ name: i18nService.t("mx"), value: i18nService.t("mx") },
|
||||
{ name: i18nService.t("dr"), value: i18nService.t("dr") },
|
||||
];
|
||||
this.uriMatchOptions = [
|
||||
{ name: i18nService.t("defaultMatchDetection"), value: null },
|
||||
{ name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
|
||||
{ name: i18nService.t("host"), value: UriMatchStrategy.Host },
|
||||
{ name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith },
|
||||
{ name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression },
|
||||
{ name: i18nService.t("exact"), value: UriMatchStrategy.Exact },
|
||||
{ name: i18nService.t("never"), value: UriMatchStrategy.Never },
|
||||
];
|
||||
this.autofillOnPageLoadOptions = [
|
||||
{ name: i18nService.t("autoFillOnPageLoadUseDefault"), value: null },
|
||||
{ name: i18nService.t("autoFillOnPageLoadYes"), value: true },
|
||||
{ name: i18nService.t("autoFillOnPageLoadNo"), value: false },
|
||||
];
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
|
||||
),
|
||||
concatMap(async (policyAppliesToActiveUser) => {
|
||||
this.organizationDataOwnershipAppliesToUser = policyAppliesToActiveUser;
|
||||
await this.init();
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.writeableCollections = await this.loadCollections();
|
||||
this.canUseReprompt = await this.passwordRepromptService.enabled();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.ownershipOptions.length) {
|
||||
this.ownershipOptions = [];
|
||||
}
|
||||
if (this.organizationDataOwnershipAppliesToUser) {
|
||||
this.allowPersonal = false;
|
||||
} else {
|
||||
const myEmail = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
this.ownershipOptions.push({ name: myEmail, value: null });
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
|
||||
);
|
||||
const orgs = await firstValueFrom(this.organizationService.organizations$(userId));
|
||||
orgs
|
||||
.filter((org) => org.isMember)
|
||||
.sort(Utils.getSortFunction(this.i18nService, "name"))
|
||||
.forEach((o) => {
|
||||
if (o.enabled && o.status === OrganizationUserStatusType.Confirmed) {
|
||||
this.ownershipOptions.push({ name: o.name, value: o.id });
|
||||
}
|
||||
});
|
||||
if (!this.allowPersonal && this.organizationId == undefined) {
|
||||
this.organizationId = this.defaultOwnerId;
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.editMode = this.cipherId != null;
|
||||
if (this.editMode) {
|
||||
this.editMode = true;
|
||||
if (this.cloneMode) {
|
||||
this.cloneMode = true;
|
||||
this.title = this.i18nService.t("addItem");
|
||||
} else {
|
||||
this.title = this.i18nService.t("editItem");
|
||||
}
|
||||
} else {
|
||||
this.title = this.i18nService.t("addItem");
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
const loadedAddEditCipherInfo = await this.loadAddEditCipherInfo(activeUserId);
|
||||
|
||||
if (this.cipher == null) {
|
||||
if (this.editMode) {
|
||||
const cipher = await this.loadCipher(activeUserId);
|
||||
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
|
||||
// Adjust Cipher Name if Cloning
|
||||
if (this.cloneMode) {
|
||||
this.cipher.name += " - " + this.i18nService.t("clone");
|
||||
// If not allowing personal ownership, update cipher's org Id to prompt downstream changes
|
||||
if (this.cipher.organizationId == null && !this.allowPersonal) {
|
||||
this.cipher.organizationId = this.organizationId;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.cipher = new CipherView();
|
||||
this.cipher.organizationId = this.organizationId == null ? null : this.organizationId;
|
||||
this.cipher.folderId = this.folderId;
|
||||
this.cipher.type = this.type == null ? CipherType.Login : this.type;
|
||||
this.cipher.login = new LoginView();
|
||||
this.cipher.login.uris = [new LoginUriView()];
|
||||
this.cipher.card = new CardView();
|
||||
this.cipher.identity = new IdentityView();
|
||||
this.cipher.secureNote = new SecureNoteView();
|
||||
this.cipher.secureNote.type = SecureNoteType.Generic;
|
||||
this.cipher.sshKey = new SshKeyView();
|
||||
this.cipher.reprompt = CipherRepromptType.None;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cipher != null && (!this.editMode || loadedAddEditCipherInfo || this.cloneMode)) {
|
||||
await this.organizationChanged();
|
||||
if (
|
||||
this.collectionIds != null &&
|
||||
this.collectionIds.length > 0 &&
|
||||
this.collections.length > 0
|
||||
) {
|
||||
this.collections.forEach((c) => {
|
||||
if (this.collectionIds.indexOf(c.id) > -1) {
|
||||
(c as any).checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only Admins can clone a cipher to different owner
|
||||
if (this.cloneMode && this.cipher.organizationId != null) {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
const cipherOrg = (
|
||||
await firstValueFrom(this.organizationService.memberOrganizations$(activeUserId))
|
||||
).find((o) => o.id === this.cipher.organizationId);
|
||||
|
||||
if (cipherOrg != null && !cipherOrg.isAdmin && !cipherOrg.permissions.editAnyCollection) {
|
||||
this.ownershipOptions = [{ name: cipherOrg.name, value: cipherOrg.id }];
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to copy passkeys when we clone a cipher
|
||||
if (this.cloneMode && this.cipher?.login?.hasFido2Credentials) {
|
||||
this.cipher.login.fido2Credentials = null;
|
||||
}
|
||||
|
||||
this.folders$ = this.folderService.folderViews$(activeUserId);
|
||||
|
||||
if (this.editMode && this.previousCipherId !== this.cipherId) {
|
||||
void this.eventCollectionService.collectMany(EventType.Cipher_ClientViewed, [this.cipher]);
|
||||
}
|
||||
this.previousCipherId = this.cipherId;
|
||||
this.reprompt = this.cipher.reprompt !== CipherRepromptType.None;
|
||||
if (this.reprompt) {
|
||||
this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value;
|
||||
}
|
||||
|
||||
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(
|
||||
this.cipher,
|
||||
this.isAdminConsoleAction,
|
||||
);
|
||||
|
||||
if (!this.editMode || this.cloneMode) {
|
||||
// Creating an ssh key directly while filtering to the ssh key category
|
||||
// must force a key to be set. SSH keys must never be created with an empty private key field
|
||||
if (
|
||||
this.cipher.type === CipherType.SshKey &&
|
||||
(this.cipher.sshKey.privateKey == null || this.cipher.sshKey.privateKey === "")
|
||||
) {
|
||||
await this.generateSshKey(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async submit(): Promise<boolean> {
|
||||
if (this.cipher.isDeleted) {
|
||||
return this.restore();
|
||||
}
|
||||
|
||||
// normalize card expiry year on save
|
||||
if (this.cipher.type === this.cipherType.Card) {
|
||||
this.cipher.card.expYear = normalizeExpiryYearFormat(this.cipher.card.expYear);
|
||||
}
|
||||
|
||||
// trim whitespace from the TOTP field
|
||||
if (this.cipher.type === this.cipherType.Login && this.cipher.login.totp) {
|
||||
this.cipher.login.totp = this.cipher.login.totp.trim();
|
||||
}
|
||||
|
||||
if (this.cipher.name == null || this.cipher.name === "") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("nameRequired"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.editMode || this.cloneMode) &&
|
||||
!this.allowPersonal &&
|
||||
this.cipher.organizationId == null
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("personalOwnershipSubmitError"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.editMode || this.cloneMode) &&
|
||||
this.cipher.type === CipherType.Login &&
|
||||
this.cipher.login.uris != null &&
|
||||
this.cipher.login.uris.length === 1 &&
|
||||
(this.cipher.login.uris[0].uri == null || this.cipher.login.uris[0].uri === "")
|
||||
) {
|
||||
this.cipher.login.uris = [];
|
||||
}
|
||||
|
||||
// Allows saving of selected collections during "Add" and "Clone" flows
|
||||
if ((!this.editMode || this.cloneMode) && this.cipher.organizationId != null) {
|
||||
this.cipher.collectionIds =
|
||||
this.collections == null
|
||||
? []
|
||||
: this.collections.filter((c) => (c as any).checked).map((c) => c.id);
|
||||
}
|
||||
|
||||
// Clear current Cipher Id if exists to trigger "Add" cipher flow
|
||||
if (this.cloneMode) {
|
||||
this.cipher.id = null;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipher = await this.encryptCipher(activeUserId);
|
||||
|
||||
try {
|
||||
this.formPromise = this.saveCipher(cipher);
|
||||
const savedCipher = await this.formPromise;
|
||||
|
||||
// Reset local cipher from the saved cipher returned from the server
|
||||
this.cipher = await savedCipher.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId),
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t(this.editMode && !this.cloneMode ? "editedItem" : "addedItem"),
|
||||
});
|
||||
this.onSavedCipher.emit(this.cipher);
|
||||
this.messagingService.send(this.editMode && !this.cloneMode ? "editedCipher" : "addedCipher");
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
addUri() {
|
||||
if (this.cipher.type !== CipherType.Login) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cipher.login.uris == null) {
|
||||
this.cipher.login.uris = [];
|
||||
}
|
||||
|
||||
this.cipher.login.uris.push(new LoginUriView());
|
||||
}
|
||||
|
||||
removeUri(uri: LoginUriView) {
|
||||
if (this.cipher.type !== CipherType.Login || this.cipher.login.uris == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i = this.cipher.login.uris.indexOf(uri);
|
||||
if (i > -1) {
|
||||
this.cipher.login.uris.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
removePasskey() {
|
||||
if (this.cipher.type !== CipherType.Login || this.cipher.login.fido2Credentials == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipher.login.fido2Credentials = null;
|
||||
}
|
||||
|
||||
onCardNumberChange(): void {
|
||||
this.cipher.card.brand = CardView.getCardBrandByPatterns(this.cipher.card.number);
|
||||
}
|
||||
|
||||
getCardExpMonthDisplay() {
|
||||
return this.cardExpMonthOptions.find((x) => x.value == this.cipher.card.expMonth)?.name;
|
||||
}
|
||||
|
||||
trackByFunction(index: number, item: any) {
|
||||
return index;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.onCancelled.emit(this.cipher);
|
||||
}
|
||||
|
||||
attachments() {
|
||||
this.onEditAttachments.emit(this.cipher);
|
||||
}
|
||||
|
||||
share() {
|
||||
this.onShareCipher.emit(this.cipher);
|
||||
}
|
||||
|
||||
editCollections() {
|
||||
this.onEditCollections.emit(this.cipher);
|
||||
}
|
||||
|
||||
async delete(): Promise<boolean> {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteItem" },
|
||||
content: {
|
||||
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
|
||||
},
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.deletePromise = this.deleteCipher(activeUserId);
|
||||
await this.deletePromise;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t(
|
||||
this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem",
|
||||
),
|
||||
});
|
||||
this.onDeletedCipher.emit(this.cipher);
|
||||
this.messagingService.send(
|
||||
this.cipher.isDeleted ? "permanentlyDeletedCipher" : "deletedCipher",
|
||||
);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async restore(): Promise<boolean> {
|
||||
if (!this.cipher.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.restorePromise = this.restoreCipher(activeUserId);
|
||||
await this.restorePromise;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("restoredItem"),
|
||||
});
|
||||
this.onRestoredCipher.emit(this.cipher);
|
||||
this.messagingService.send("restoredCipher");
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateUsername(): Promise<boolean> {
|
||||
if (this.cipher.login?.username?.length) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "overwriteUsername" },
|
||||
content: { key: "overwriteUsernameConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.onGenerateUsername.emit();
|
||||
return true;
|
||||
}
|
||||
|
||||
async generatePassword(): Promise<boolean> {
|
||||
if (this.cipher.login?.password?.length) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "overwritePassword" },
|
||||
content: { key: "overwritePasswordConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.onGeneratePassword.emit();
|
||||
return true;
|
||||
}
|
||||
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
|
||||
if (this.editMode && this.showPassword) {
|
||||
document.getElementById("loginPassword")?.focus();
|
||||
|
||||
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledPasswordVisible, [
|
||||
this.cipher,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
toggleTotpSeed() {
|
||||
this.showTotpSeed = !this.showTotpSeed;
|
||||
|
||||
if (this.editMode && this.showTotpSeed) {
|
||||
document.getElementById("loginTotp")?.focus();
|
||||
|
||||
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledTOTPSeedVisible, [
|
||||
this.cipher,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleCardNumber() {
|
||||
this.showCardNumber = !this.showCardNumber;
|
||||
if (this.showCardNumber) {
|
||||
void this.eventCollectionService.collectMany(
|
||||
EventType.Cipher_ClientToggledCardNumberVisible,
|
||||
[this.cipher],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toggleCardCode() {
|
||||
this.showCardCode = !this.showCardCode;
|
||||
document.getElementById("cardCode").focus();
|
||||
if (this.editMode && this.showCardCode) {
|
||||
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledCardCodeVisible, [
|
||||
this.cipher,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivateKey() {
|
||||
this.showPrivateKey = !this.showPrivateKey;
|
||||
}
|
||||
|
||||
toggleUriOptions(uri: LoginUriView) {
|
||||
const u = uri as any;
|
||||
u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions;
|
||||
}
|
||||
|
||||
loginUriMatchChanged(uri: LoginUriView) {
|
||||
const u = uri as any;
|
||||
u.showOptions = u.showOptions == null ? true : u.showOptions;
|
||||
}
|
||||
|
||||
async organizationChanged() {
|
||||
if (this.writeableCollections != null) {
|
||||
this.writeableCollections.forEach((c) => ((c as any).checked = false));
|
||||
}
|
||||
if (this.cipher.organizationId != null) {
|
||||
this.collections = this.writeableCollections?.filter(
|
||||
(c) => c.organizationId === this.cipher.organizationId,
|
||||
);
|
||||
// If there's only one collection, check it by default
|
||||
if (this.collections.length === 1) {
|
||||
(this.collections[0] as any).checked = true;
|
||||
}
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
const org = (
|
||||
await firstValueFrom(this.organizationService.organizations$(activeUserId))
|
||||
).find((org) => org.id === this.cipher.organizationId);
|
||||
if (org != null) {
|
||||
this.cipher.organizationUseTotp = org.useTotp;
|
||||
}
|
||||
} else {
|
||||
this.collections = [];
|
||||
}
|
||||
}
|
||||
|
||||
async checkPassword() {
|
||||
if (this.checkPasswordPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.cipher.login == null ||
|
||||
this.cipher.login.password == null ||
|
||||
this.cipher.login.password === ""
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkPasswordPromise = this.auditService.passwordLeaked(this.cipher.login.password);
|
||||
const matches = await this.checkPasswordPromise;
|
||||
this.checkPasswordPromise = null;
|
||||
|
||||
if (matches > 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "warning",
|
||||
title: null,
|
||||
message: this.i18nService.t("passwordExposed", matches.toString()),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("passwordSafe"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
repromptChanged() {
|
||||
this.reprompt = !this.reprompt;
|
||||
if (this.reprompt) {
|
||||
this.cipher.reprompt = CipherRepromptType.Password;
|
||||
this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value;
|
||||
} else {
|
||||
this.cipher.reprompt = CipherRepromptType.None;
|
||||
this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
protected async loadCollections() {
|
||||
const allCollections = await this.collectionService.getAllDecrypted();
|
||||
return allCollections.filter((c) => !c.readOnly);
|
||||
}
|
||||
|
||||
protected loadCipher(userId: UserId) {
|
||||
return this.cipherService.get(this.cipherId, userId);
|
||||
}
|
||||
|
||||
protected encryptCipher(userId: UserId) {
|
||||
return this.cipherService.encrypt(this.cipher, userId);
|
||||
}
|
||||
|
||||
protected saveCipher(data: EncryptionContext) {
|
||||
let orgAdmin = this.organization?.canEditAllCiphers;
|
||||
|
||||
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
|
||||
if (!data.cipher.collectionIds) {
|
||||
orgAdmin = this.organization?.canEditUnassignedCiphers;
|
||||
}
|
||||
|
||||
return this.cipher.id == null
|
||||
? this.cipherService.createWithServer(data, orgAdmin)
|
||||
: this.cipherService.updateWithServer(data, orgAdmin);
|
||||
}
|
||||
|
||||
protected deleteCipher(userId: UserId) {
|
||||
return this.cipher.isDeleted
|
||||
? this.cipherService.deleteWithServer(this.cipher.id, userId, this.asAdmin)
|
||||
: this.cipherService.softDeleteWithServer(this.cipher.id, userId, this.asAdmin);
|
||||
}
|
||||
|
||||
protected restoreCipher(userId: UserId) {
|
||||
return this.cipherService.restoreWithServer(this.cipher.id, userId, this.asAdmin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a cipher must be deleted as an admin by belonging to an organization and being unassigned to a collection.
|
||||
*/
|
||||
get asAdmin(): boolean {
|
||||
return (
|
||||
this.cipher.organizationId !== null &&
|
||||
this.cipher.organizationId.length > 0 &&
|
||||
(this.organization?.canEditAllCiphers ||
|
||||
!this.cipher.collectionIds ||
|
||||
this.cipher.collectionIds.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
get defaultOwnerId(): string | null {
|
||||
return this.ownershipOptions[0].value;
|
||||
}
|
||||
|
||||
async loadAddEditCipherInfo(userId: UserId): Promise<boolean> {
|
||||
const addEditCipherInfo: any = await firstValueFrom(
|
||||
this.cipherService.addEditCipherInfo$(userId),
|
||||
);
|
||||
const loadedSavedInfo = addEditCipherInfo != null;
|
||||
|
||||
if (loadedSavedInfo) {
|
||||
this.cipher = addEditCipherInfo.cipher;
|
||||
this.collectionIds = addEditCipherInfo.collectionIds;
|
||||
|
||||
if (!this.editMode && !this.allowPersonal && this.cipher.organizationId == null) {
|
||||
// This is a new cipher and personal ownership isn't allowed, so we need to set the default owner
|
||||
this.cipher.organizationId = this.defaultOwnerId;
|
||||
}
|
||||
}
|
||||
|
||||
await this.cipherService.setAddEditCipherInfo(null, userId);
|
||||
|
||||
return loadedSavedInfo;
|
||||
}
|
||||
|
||||
async copy(value: string, typeI18nKey: string, aType: string): Promise<boolean> {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const copyOptions = this.win != null ? { window: this.win } : null;
|
||||
this.platformUtilsService.copyToClipboard(value, copyOptions);
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)),
|
||||
});
|
||||
|
||||
if (typeI18nKey === "password") {
|
||||
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedPassword, [
|
||||
this.cipher,
|
||||
]);
|
||||
} else if (typeI18nKey === "securityCode") {
|
||||
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedCardCode, [
|
||||
this.cipher,
|
||||
]);
|
||||
} else if (aType === "H_Field") {
|
||||
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedHiddenField, [
|
||||
this.cipher,
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async importSshKeyFromClipboard() {
|
||||
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
|
||||
if (key != null) {
|
||||
this.cipher.sshKey.privateKey = key.privateKey;
|
||||
this.cipher.sshKey.publicKey = key.publicKey;
|
||||
this.cipher.sshKey.keyFingerprint = key.keyFingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateSshKey(showNotification: boolean = true) {
|
||||
await firstValueFrom(this.sdkService.client$);
|
||||
const sshKey = generate_ssh_key("Ed25519");
|
||||
this.cipher.sshKey.privateKey = sshKey.privateKey;
|
||||
this.cipher.sshKey.publicKey = sshKey.publicKey;
|
||||
this.cipher.sshKey.keyFingerprint = sshKey.fingerprint;
|
||||
|
||||
if (showNotification) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyGenerated"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async typeChange() {
|
||||
if (this.cipher.type === CipherType.SshKey) {
|
||||
await this.generateSshKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@Directive()
|
||||
export class AttachmentsComponent implements OnInit {
|
||||
@Input() cipherId: string;
|
||||
@Input() viewOnly: boolean;
|
||||
@Output() onUploadedAttachment = new EventEmitter<CipherView>();
|
||||
@Output() onDeletedAttachment = new EventEmitter();
|
||||
@Output() onReuploadedAttachment = new EventEmitter();
|
||||
|
||||
cipher: CipherView;
|
||||
cipherDomain: Cipher;
|
||||
canAccessAttachments: boolean;
|
||||
formPromise: Promise<any>;
|
||||
deletePromises: { [id: string]: Promise<CipherData> } = {};
|
||||
reuploadPromises: { [id: string]: Promise<any> } = {};
|
||||
emergencyAccessId?: string = null;
|
||||
protected componentName = "";
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected i18nService: I18nService,
|
||||
protected keyService: KeyService,
|
||||
protected encryptService: EncryptService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected apiService: ApiService,
|
||||
protected win: Window,
|
||||
protected logService: LogService,
|
||||
protected stateService: StateService,
|
||||
protected fileDownloadService: FileDownloadService,
|
||||
protected dialogService: DialogService,
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
protected configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const fileEl = document.getElementById("file") as HTMLInputElement;
|
||||
const files = fileEl.files;
|
||||
if (files == null || files.length === 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("selectFile"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (files[0].size > 524288000) {
|
||||
// 500 MB
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("maxFileSize"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.formPromise = this.saveCipherAttachment(files[0], activeUserId);
|
||||
this.cipherDomain = await this.formPromise;
|
||||
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("attachmentSaved"),
|
||||
});
|
||||
this.onUploadedAttachment.emit(this.cipher);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
// reset file input
|
||||
// ref: https://stackoverflow.com/a/20552042
|
||||
fileEl.type = "";
|
||||
fileEl.type = "file";
|
||||
fileEl.value = "";
|
||||
}
|
||||
|
||||
async delete(attachment: AttachmentView) {
|
||||
if (this.deletePromises[attachment.id] != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteAttachment" },
|
||||
content: { key: "deleteAttachmentConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id, activeUserId);
|
||||
const updatedCipher = await this.deletePromises[attachment.id];
|
||||
|
||||
const cipher = new Cipher(updatedCipher);
|
||||
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("deletedAttachment"),
|
||||
});
|
||||
const i = this.cipher.attachments.indexOf(attachment);
|
||||
if (i > -1) {
|
||||
this.cipher.attachments.splice(i, 1);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
this.deletePromises[attachment.id] = null;
|
||||
this.onDeletedAttachment.emit(this.cipher);
|
||||
}
|
||||
|
||||
async download(attachment: AttachmentView) {
|
||||
const a = attachment as any;
|
||||
if (a.downloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.canAccessAttachments) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("premiumRequired"),
|
||||
message: this.i18nService.t("premiumRequiredDesc"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
|
||||
this.cipher.id,
|
||||
attachment.id,
|
||||
this.emergencyAccessId,
|
||||
);
|
||||
url = attachmentDownloadResponse.url;
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
|
||||
url = attachment.url;
|
||||
} else if (e instanceof ErrorResponse) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
a.downloading = true;
|
||||
const response = await fetch(new Request(url, { cache: "no-store" }));
|
||||
if (response.status !== 200) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
a.downloading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
|
||||
this.cipherDomain.id as CipherId,
|
||||
attachment,
|
||||
response,
|
||||
activeUserId,
|
||||
);
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: attachment.fileName,
|
||||
blobData: decBuf,
|
||||
});
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("fileSavedToDevice"),
|
||||
});
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
|
||||
a.downloading = false;
|
||||
}
|
||||
|
||||
protected async init() {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.cipherDomain = await this.loadCipher(activeUserId);
|
||||
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
|
||||
|
||||
const canAccessPremium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
|
||||
);
|
||||
this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null;
|
||||
|
||||
if (!this.canAccessAttachments) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "premiumRequired" },
|
||||
content: { key: "premiumRequiredDesc" },
|
||||
acceptButtonText: { key: "learnMore" },
|
||||
type: "success",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.platformUtilsService.launchUri(
|
||||
"https://vault.bitwarden.com/#/settings/subscription/premium",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async reuploadCipherAttachment(attachment: AttachmentView, admin: boolean) {
|
||||
const a = attachment as any;
|
||||
if (attachment.key != null || a.downloading || this.reuploadPromises[attachment.id] != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.reuploadPromises[attachment.id] = Promise.resolve().then(async () => {
|
||||
// 1. Download
|
||||
a.downloading = true;
|
||||
const response = await fetch(new Request(attachment.url, { cache: "no-store" }));
|
||||
if (response.status !== 200) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
a.downloading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Resave
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
);
|
||||
|
||||
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
|
||||
this.cipherDomain.id as CipherId,
|
||||
attachment,
|
||||
response,
|
||||
activeUserId,
|
||||
);
|
||||
|
||||
this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer(
|
||||
this.cipherDomain,
|
||||
attachment.fileName,
|
||||
decBuf,
|
||||
activeUserId,
|
||||
admin,
|
||||
);
|
||||
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
|
||||
|
||||
// 3. Delete old
|
||||
this.deletePromises[attachment.id] = this.deleteCipherAttachment(
|
||||
attachment.id,
|
||||
activeUserId,
|
||||
);
|
||||
await this.deletePromises[attachment.id];
|
||||
const foundAttachment = this.cipher.attachments.filter((a2) => a2.id === attachment.id);
|
||||
if (foundAttachment.length > 0) {
|
||||
const i = this.cipher.attachments.indexOf(foundAttachment[0]);
|
||||
if (i > -1) {
|
||||
this.cipher.attachments.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("attachmentSaved"),
|
||||
});
|
||||
this.onReuploadedAttachment.emit();
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
|
||||
a.downloading = false;
|
||||
});
|
||||
await this.reuploadPromises[attachment.id];
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected loadCipher(userId: UserId) {
|
||||
return this.cipherService.get(this.cipherId, userId);
|
||||
}
|
||||
|
||||
protected saveCipherAttachment(file: File, userId: UserId) {
|
||||
return this.cipherService.saveAttachmentWithServer(this.cipherDomain, file, userId);
|
||||
}
|
||||
|
||||
protected deleteCipherAttachment(attachmentId: string, userId: UserId) {
|
||||
return this.cipherService.deleteAttachmentWithServer(
|
||||
this.cipher.id,
|
||||
attachmentId,
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected async reupload(attachment: AttachmentView) {
|
||||
// TODO: This should be removed but is needed since we re-use the same template
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-icon",
|
||||
@@ -25,7 +25,7 @@ export class IconComponent {
|
||||
/**
|
||||
* The cipher to display the icon for.
|
||||
*/
|
||||
cipher = input.required<CipherView>();
|
||||
cipher = input.required<CipherViewLike>();
|
||||
|
||||
imageLoaded = signal(false);
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnInit } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
@Directive()
|
||||
export class PasswordHistoryComponent implements OnInit {
|
||||
cipherId: string;
|
||||
history: PasswordHistoryView[] = [];
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected i18nService: I18nService,
|
||||
protected accountService: AccountService,
|
||||
private win: Window,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
copy(password: string) {
|
||||
const copyOptions = this.win != null ? { window: this.win } : null;
|
||||
this.platformUtilsService.copyToClipboard(password, copyOptions);
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("valueCopied", this.i18nService.t("password")),
|
||||
});
|
||||
}
|
||||
|
||||
protected async init() {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipher = await this.cipherService.get(this.cipherId, activeUserId);
|
||||
const decCipher = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-3 tw-flex tw-flex-col tw-gap-2"
|
||||
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-3 tw-flex tw-flex-col tw-gap-2 tw-mb-4"
|
||||
>
|
||||
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
|
||||
<div>
|
||||
|
||||
@@ -15,26 +15,29 @@ import {
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
@Directive()
|
||||
export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
export class VaultItemsComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
|
||||
@Input() activeCipherId: string = null;
|
||||
@Output() onCipherClicked = new EventEmitter<CipherView>();
|
||||
@Output() onCipherRightClicked = new EventEmitter<CipherView>();
|
||||
@Output() onCipherClicked = new EventEmitter<C>();
|
||||
@Output() onCipherRightClicked = new EventEmitter<C>();
|
||||
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
|
||||
@Output() onAddCipherOptions = new EventEmitter();
|
||||
|
||||
loaded = false;
|
||||
ciphers: CipherView[] = [];
|
||||
ciphers: C[] = [];
|
||||
deleted = false;
|
||||
organization: Organization;
|
||||
CipherType = CipherType;
|
||||
@@ -55,7 +58,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
protected searchPending = false;
|
||||
|
||||
/** Construct filters as an observable so it can be appended to the cipher stream. */
|
||||
private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null);
|
||||
private _filter$ = new BehaviorSubject<(cipher: C) => boolean | null>(null);
|
||||
private destroy$ = new Subject<void>();
|
||||
private isSearchable: boolean = false;
|
||||
private _searchText$ = new BehaviorSubject<string>("");
|
||||
@@ -71,7 +74,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
return this._filter$.value;
|
||||
}
|
||||
|
||||
set filter(value: (cipher: CipherView) => boolean | null) {
|
||||
set filter(value: (cipher: C) => boolean | null) {
|
||||
this._filter$.next(value);
|
||||
}
|
||||
|
||||
@@ -102,13 +105,13 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
||||
async load(filter: (cipher: C) => boolean = null, deleted = false) {
|
||||
this.deleted = deleted ?? false;
|
||||
await this.applyFilter(filter);
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async reload(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
||||
async reload(filter: (cipher: C) => boolean = null, deleted = false) {
|
||||
this.loaded = false;
|
||||
await this.load(filter, deleted);
|
||||
}
|
||||
@@ -117,15 +120,15 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
await this.reload(this.filter, this.deleted);
|
||||
}
|
||||
|
||||
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
|
||||
async applyFilter(filter: (cipher: C) => boolean = null) {
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
selectCipher(cipher: CipherView) {
|
||||
selectCipher(cipher: C) {
|
||||
this.onCipherClicked.emit(cipher);
|
||||
}
|
||||
|
||||
rightClickCipher(cipher: CipherView) {
|
||||
rightClickCipher(cipher: C) {
|
||||
this.onCipherRightClicked.emit(cipher);
|
||||
}
|
||||
|
||||
@@ -141,7 +144,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
return !this.searchPending && this.isSearchable;
|
||||
}
|
||||
|
||||
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
|
||||
protected deletedFilter: (cipher: C) => boolean = (c) =>
|
||||
CipherViewLikeUtils.isDeleted(c) === this.deleted;
|
||||
|
||||
/**
|
||||
* Creates stream of dependencies that results in the list of ciphers to display
|
||||
@@ -156,7 +160,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
|
||||
this.cipherService.cipherListViews$(userId).pipe(filter((ciphers) => ciphers != null)),
|
||||
this.cipherService.failedToDecryptCiphers$(userId),
|
||||
this._searchText$,
|
||||
this._filter$,
|
||||
@@ -165,12 +169,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
]),
|
||||
),
|
||||
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => {
|
||||
let allCiphers = indexedCiphers ?? [];
|
||||
let allCiphers = (indexedCiphers ?? []) as C[];
|
||||
const _failedCiphers = failedCiphers ?? [];
|
||||
|
||||
allCiphers = [..._failedCiphers, ...allCiphers];
|
||||
allCiphers = [..._failedCiphers, ...allCiphers] as C[];
|
||||
|
||||
const restrictedTypeFilter = (cipher: CipherView) =>
|
||||
const restrictedTypeFilter = (cipher: CipherViewLike) =>
|
||||
!this.restrictedItemTypesService.isCipherRestricted(cipher, restricted);
|
||||
|
||||
return this.searchService.searchCiphers(
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, Input } from "@angular/core";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FieldType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
|
||||
@Directive()
|
||||
export class ViewCustomFieldsComponent {
|
||||
@Input() cipher: CipherView;
|
||||
@Input() promptPassword: () => Promise<boolean>;
|
||||
@Input() copy: (value: string, typeI18nKey: string, aType: string) => void;
|
||||
|
||||
fieldType = FieldType;
|
||||
|
||||
constructor(private eventCollectionService: EventCollectionService) {}
|
||||
|
||||
async toggleFieldValue(field: FieldView) {
|
||||
if (!(await this.promptPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const f = field as any;
|
||||
f.showValue = !f.showValue;
|
||||
f.showCount = false;
|
||||
if (f.showValue) {
|
||||
// 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.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledHiddenFieldVisible,
|
||||
this.cipher.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleFieldCount(field: FieldView) {
|
||||
if (!field.showValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
field.showCount = !field.showCount;
|
||||
}
|
||||
|
||||
setTextDataOnDrag(event: DragEvent, data: string) {
|
||||
event.dataTransfer.setData("text", data);
|
||||
}
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DatePipe } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Directive,
|
||||
EventEmitter,
|
||||
Input,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
} from "@angular/core";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherType, FieldType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { TotpInfo } from "@bitwarden/common/vault/services/totp.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
const BroadcasterSubscriptionId = "BaseViewComponent";
|
||||
|
||||
@Directive()
|
||||
export class ViewComponent implements OnDestroy, OnInit {
|
||||
/** Observable of cipherId$ that will update each time the `Input` updates */
|
||||
private _cipherId$ = new BehaviorSubject<string>(null);
|
||||
|
||||
@Input()
|
||||
set cipherId(value: string) {
|
||||
this._cipherId$.next(value);
|
||||
}
|
||||
|
||||
get cipherId(): string {
|
||||
return this._cipherId$.getValue();
|
||||
}
|
||||
|
||||
@Input() collectionId: string;
|
||||
@Output() onEditCipher = new EventEmitter<CipherView>();
|
||||
@Output() onCloneCipher = new EventEmitter<CipherView>();
|
||||
@Output() onShareCipher = new EventEmitter<CipherView>();
|
||||
@Output() onDeletedCipher = new EventEmitter<CipherView>();
|
||||
@Output() onRestoredCipher = new EventEmitter<CipherView>();
|
||||
|
||||
canDeleteCipher$: Observable<boolean>;
|
||||
canRestoreCipher$: Observable<boolean>;
|
||||
cipher: CipherView;
|
||||
showPassword: boolean;
|
||||
showPasswordCount: boolean;
|
||||
showCardNumber: boolean;
|
||||
showCardCode: boolean;
|
||||
showPrivateKey: boolean;
|
||||
canAccessPremium: boolean;
|
||||
showPremiumRequiredTotp: boolean;
|
||||
fieldType = FieldType;
|
||||
checkPasswordPromise: Promise<number>;
|
||||
folder: FolderView;
|
||||
cipherType = CipherType;
|
||||
|
||||
private previousCipherId: string;
|
||||
protected passwordReprompted = false;
|
||||
|
||||
/**
|
||||
* Represents TOTP information including display formatting and timing
|
||||
*/
|
||||
protected totpInfo$: Observable<TotpInfo> | undefined;
|
||||
|
||||
get fido2CredentialCreationDateValue(): string {
|
||||
const dateCreated = this.i18nService.t("dateCreated");
|
||||
const creationDate = this.datePipe.transform(
|
||||
this.cipher?.login?.fido2Credentials?.[0]?.creationDate,
|
||||
"short",
|
||||
);
|
||||
return `${dateCreated} ${creationDate}`;
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected folderService: FolderService,
|
||||
protected totpService: TotpService,
|
||||
protected tokenService: TokenService,
|
||||
protected i18nService: I18nService,
|
||||
protected keyService: KeyService,
|
||||
protected encryptService: EncryptService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected auditService: AuditService,
|
||||
protected win: Window,
|
||||
protected broadcasterService: BroadcasterService,
|
||||
protected ngZone: NgZone,
|
||||
protected changeDetectorRef: ChangeDetectorRef,
|
||||
protected eventCollectionService: EventCollectionService,
|
||||
protected apiService: ApiService,
|
||||
protected passwordRepromptService: PasswordRepromptService,
|
||||
private logService: LogService,
|
||||
protected stateService: StateService,
|
||||
protected fileDownloadService: FileDownloadService,
|
||||
protected dialogService: DialogService,
|
||||
protected datePipe: DatePipe,
|
||||
protected accountService: AccountService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected toastService: ToastService,
|
||||
private cipherAuthorizationService: CipherAuthorizationService,
|
||||
protected configService: ConfigService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
||||
// 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.ngZone.run(async () => {
|
||||
switch (message.command) {
|
||||
case "syncCompleted":
|
||||
if (message.successfully) {
|
||||
this.changeDetectorRef.detectChanges();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set up the subscription to the activeAccount$ and cipherId$ observables
|
||||
combineLatest([this.accountService.activeAccount$.pipe(getUserId), this._cipherId$])
|
||||
.pipe(
|
||||
tap(() => this.cleanUp()),
|
||||
switchMap(([userId, cipherId]) => {
|
||||
const cipher$ = this.cipherService.cipherViews$(userId).pipe(
|
||||
map((ciphers) => ciphers?.find((c) => c.id === cipherId)),
|
||||
filter((cipher) => !!cipher),
|
||||
);
|
||||
return combineLatest([of(userId), cipher$]);
|
||||
}),
|
||||
)
|
||||
.subscribe(([userId, cipher]) => {
|
||||
this.cipher = cipher;
|
||||
|
||||
void this.constructCipherDetails(userId);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||
this.cleanUp();
|
||||
}
|
||||
|
||||
async edit() {
|
||||
this.onEditCipher.emit(this.cipher);
|
||||
}
|
||||
|
||||
async clone() {
|
||||
if (this.cipher.login?.hasFido2Credentials) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "passkeyNotCopied" },
|
||||
content: { key: "passkeyNotCopiedAlert" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.promptPassword()) {
|
||||
this.onCloneCipher.emit(this.cipher);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async share() {
|
||||
if (await this.promptPassword()) {
|
||||
this.onShareCipher.emit(this.cipher);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async delete(): Promise<boolean> {
|
||||
if (!(await this.promptPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteItem" },
|
||||
content: {
|
||||
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
|
||||
},
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.deleteCipher(activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t(
|
||||
this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem",
|
||||
),
|
||||
});
|
||||
this.onDeletedCipher.emit(this.cipher);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async restore(): Promise<boolean> {
|
||||
if (!this.cipher.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.restoreCipher(activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("restoredItem"),
|
||||
});
|
||||
this.onRestoredCipher.emit(this.cipher);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async togglePassword() {
|
||||
if (!(await this.promptPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showPassword = !this.showPassword;
|
||||
this.showPasswordCount = false;
|
||||
if (this.showPassword) {
|
||||
// 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.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledPasswordVisible,
|
||||
this.cipherId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async togglePasswordCount() {
|
||||
if (!this.showPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showPasswordCount = !this.showPasswordCount;
|
||||
}
|
||||
|
||||
async toggleCardNumber() {
|
||||
if (!(await this.promptPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showCardNumber = !this.showCardNumber;
|
||||
if (this.showCardNumber) {
|
||||
// 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.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledCardNumberVisible,
|
||||
this.cipherId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleCardCode() {
|
||||
if (!(await this.promptPassword())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showCardCode = !this.showCardCode;
|
||||
if (this.showCardCode) {
|
||||
// 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.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientToggledCardCodeVisible,
|
||||
this.cipherId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivateKey() {
|
||||
this.showPrivateKey = !this.showPrivateKey;
|
||||
}
|
||||
|
||||
async checkPassword() {
|
||||
if (
|
||||
this.cipher.login == null ||
|
||||
this.cipher.login.password == null ||
|
||||
this.cipher.login.password === ""
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkPasswordPromise = this.auditService.passwordLeaked(this.cipher.login.password);
|
||||
const matches = await this.checkPasswordPromise;
|
||||
|
||||
if (matches > 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "warning",
|
||||
title: null,
|
||||
message: this.i18nService.t("passwordExposed", matches.toString()),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("passwordSafe"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async launch(uri: Launchable, cipherId?: string) {
|
||||
if (!uri.canLaunch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cipherId) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.cipherService.updateLastLaunchedDate(cipherId, activeUserId);
|
||||
}
|
||||
|
||||
this.platformUtilsService.launchUri(uri.launchUri);
|
||||
}
|
||||
|
||||
async copy(value: string, typeI18nKey: string, aType: string): Promise<boolean> {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.passwordRepromptService.protectedFields().includes(aType) &&
|
||||
!(await this.promptPassword())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const copyOptions = this.win != null ? { window: this.win } : null;
|
||||
this.platformUtilsService.copyToClipboard(value, copyOptions);
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)),
|
||||
});
|
||||
|
||||
if (typeI18nKey === "password") {
|
||||
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, this.cipherId);
|
||||
} else if (typeI18nKey === "securityCode") {
|
||||
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedCardCode, this.cipherId);
|
||||
} else if (aType === "H_Field") {
|
||||
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedHiddenField, this.cipherId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setTextDataOnDrag(event: DragEvent, data: string) {
|
||||
event.dataTransfer.setData("text", data);
|
||||
}
|
||||
|
||||
async downloadAttachment(attachment: AttachmentView) {
|
||||
if (!(await this.promptPassword())) {
|
||||
return;
|
||||
}
|
||||
const a = attachment as any;
|
||||
if (a.downloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cipher.organizationId == null && !this.canAccessPremium) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("premiumRequired"),
|
||||
message: this.i18nService.t("premiumRequiredDesc"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
|
||||
this.cipher.id,
|
||||
attachment.id,
|
||||
);
|
||||
url = attachmentDownloadResponse.url;
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
|
||||
url = attachment.url;
|
||||
} else if (e instanceof ErrorResponse) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
a.downloading = true;
|
||||
const response = await fetch(new Request(url, { cache: "no-store" }));
|
||||
if (response.status !== 200) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
a.downloading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
|
||||
this.cipher.id as CipherId,
|
||||
attachment,
|
||||
response,
|
||||
activeUserId,
|
||||
);
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: attachment.fileName,
|
||||
blobData: decBuf,
|
||||
});
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
|
||||
a.downloading = false;
|
||||
}
|
||||
|
||||
protected deleteCipher(userId: UserId) {
|
||||
return this.cipher.isDeleted
|
||||
? this.cipherService.deleteWithServer(this.cipher.id, userId)
|
||||
: this.cipherService.softDeleteWithServer(this.cipher.id, userId);
|
||||
}
|
||||
|
||||
protected restoreCipher(userId: UserId) {
|
||||
return this.cipherService.restoreWithServer(this.cipher.id, userId);
|
||||
}
|
||||
|
||||
protected async promptPassword() {
|
||||
if (this.cipher.reprompt === CipherRepromptType.None || this.passwordReprompted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt());
|
||||
}
|
||||
|
||||
private cleanUp() {
|
||||
this.cipher = null;
|
||||
this.folder = null;
|
||||
this.showPassword = false;
|
||||
this.showCardNumber = false;
|
||||
this.showCardCode = false;
|
||||
this.passwordReprompted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a cipher is viewed, construct all details for the view that are not directly
|
||||
* available from the cipher object itself.
|
||||
*/
|
||||
private async constructCipherDetails(userId: UserId) {
|
||||
this.canAccessPremium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
|
||||
);
|
||||
this.showPremiumRequiredTotp =
|
||||
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
|
||||
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher);
|
||||
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
|
||||
|
||||
if (this.cipher.folderId) {
|
||||
this.folder = await (
|
||||
await firstValueFrom(this.folderService.folderViews$(userId))
|
||||
).find((f) => f.id == this.cipher.folderId);
|
||||
}
|
||||
|
||||
const canGenerateTotp =
|
||||
this.cipher.type === CipherType.Login &&
|
||||
this.cipher.login.totp &&
|
||||
(this.cipher.organizationUseTotp || this.canAccessPremium);
|
||||
|
||||
this.totpInfo$ = canGenerateTotp
|
||||
? this.totpService.getCode$(this.cipher.login.totp).pipe(
|
||||
map((response) => {
|
||||
const epoch = Math.round(new Date().getTime() / 1000.0);
|
||||
const mod = epoch % response.period;
|
||||
|
||||
// Format code
|
||||
const totpCodeFormatted =
|
||||
response.code.length > 4
|
||||
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
|
||||
: response.code;
|
||||
|
||||
return {
|
||||
totpCode: response.code,
|
||||
totpCodeFormatted,
|
||||
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
|
||||
totpSec: response.period - mod,
|
||||
totpLow: response.period - mod <= 7,
|
||||
} as TotpInfo;
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (this.previousCipherId !== this.cipherId) {
|
||||
// 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.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
|
||||
}
|
||||
this.previousCipherId = this.cipherId;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
@@ -24,7 +23,6 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
|
||||
private vaultProfileService = inject(VaultProfileService);
|
||||
private logService = inject(LogService);
|
||||
private pinService = inject(PinServiceAbstraction);
|
||||
private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
private biometricStateService = inject(BiometricStateService);
|
||||
private policyService = inject(PolicyService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
@@ -76,7 +74,10 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
|
||||
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
|
||||
};
|
||||
|
||||
if (isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) {
|
||||
if (
|
||||
(isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) &&
|
||||
!status.hasSpotlightDismissed
|
||||
) {
|
||||
await this.setNudgeStatus(nudgeType, acctSecurityNudgeStatus, userId);
|
||||
}
|
||||
return acctSecurityNudgeStatus;
|
||||
|
||||
@@ -25,9 +25,9 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
|
||||
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return combineLatest([
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
this.cipherService.cipherViews$(userId),
|
||||
this.cipherService.cipherListViews$(userId),
|
||||
this.organizationService.organizations$(userId),
|
||||
this.collectionService.decryptedCollections$,
|
||||
this.collectionService.decryptedCollections$(userId),
|
||||
]).pipe(
|
||||
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
|
||||
const vaultHasContents = !(ciphers == null || ciphers.length === 0);
|
||||
@@ -42,7 +42,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
|
||||
const orgIds = new Set(orgs.map((org) => org.id));
|
||||
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
|
||||
const hasManageCollections = collections.some(
|
||||
(c) => c.manage && orgIds.has(c.organizationId),
|
||||
(c) => c.manage && orgIds.has(c.organizationId!),
|
||||
);
|
||||
|
||||
// When the user has dismissed the nudge or spotlight, return the nudge status directly
|
||||
|
||||
@@ -44,7 +44,11 @@ export class HasItemsNudgeService extends DefaultSingleNudgeService {
|
||||
return cipher.deletedDate == null;
|
||||
});
|
||||
|
||||
if (profileOlderThanCutoff && filteredCiphers.length > 0) {
|
||||
if (
|
||||
profileOlderThanCutoff &&
|
||||
filteredCiphers.length > 0 &&
|
||||
!nudgeStatus.hasSpotlightDismissed
|
||||
) {
|
||||
const dismissedStatus = {
|
||||
hasSpotlightDismissed: true,
|
||||
hasBadgeDismissed: true,
|
||||
|
||||
@@ -49,7 +49,7 @@ export class NewItemNudgeService extends DefaultSingleNudgeService {
|
||||
|
||||
const ciphersBoolean = ciphers.some((cipher) => cipher.type === currentType);
|
||||
|
||||
if (ciphersBoolean) {
|
||||
if (ciphersBoolean && !nudgeStatus.hasSpotlightDismissed) {
|
||||
const dismissedStatus = {
|
||||
hasSpotlightDismissed: true,
|
||||
hasBadgeDismissed: true,
|
||||
|
||||
@@ -27,7 +27,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
this.cipherService.cipherViews$(userId),
|
||||
this.organizationService.organizations$(userId),
|
||||
this.collectionService.decryptedCollections$,
|
||||
this.collectionService.decryptedCollections$(userId),
|
||||
]).pipe(
|
||||
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
|
||||
const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1;
|
||||
@@ -46,7 +46,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
|
||||
const orgIds = new Set(orgs.map((org) => org.id));
|
||||
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
|
||||
const hasManageCollections = collections.some(
|
||||
(c) => c.manage && orgIds.has(c.organizationId),
|
||||
(c) => c.manage && orgIds.has(c.organizationId!),
|
||||
);
|
||||
|
||||
// When the user has dismissed the nudge or spotlight, return the nudge status directly
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
HasItemsNudgeService,
|
||||
EmptyVaultNudgeService,
|
||||
NewAccountNudgeService,
|
||||
AccountSecurityNudgeService,
|
||||
VaultSettingsImportNudgeService,
|
||||
} from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
|
||||
@@ -37,7 +38,11 @@ describe("Vault Nudges Service", () => {
|
||||
getFeatureFlag: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const nudgeServices = [EmptyVaultNudgeService, NewAccountNudgeService];
|
||||
const nudgeServices = [
|
||||
EmptyVaultNudgeService,
|
||||
NewAccountNudgeService,
|
||||
AccountSecurityNudgeService,
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
@@ -68,6 +73,10 @@ describe("Vault Nudges Service", () => {
|
||||
provide: EmptyVaultNudgeService,
|
||||
useValue: mock<EmptyVaultNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: AccountSecurityNudgeService,
|
||||
useValue: mock<AccountSecurityNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: VaultSettingsImportNudgeService,
|
||||
useValue: mock<VaultSettingsImportNudgeService>(),
|
||||
|
||||
@@ -7,6 +7,9 @@ import { firstValueFrom, Observable } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
@@ -29,6 +32,8 @@ export class VaultFilterComponent implements OnInit {
|
||||
@Output() onAddFolder = new EventEmitter<never>();
|
||||
@Output() onEditFolder = new EventEmitter<FolderView>();
|
||||
|
||||
private activeUserId: UserId;
|
||||
|
||||
isLoaded = false;
|
||||
collapsedFilterNodes: Set<string>;
|
||||
organizations: Organization[];
|
||||
@@ -37,14 +42,20 @@ export class VaultFilterComponent implements OnInit {
|
||||
collections: DynamicTreeNode<CollectionView>;
|
||||
folders$: Observable<DynamicTreeNode<FolderView>>;
|
||||
|
||||
constructor(protected vaultFilterService: DeprecatedVaultFilterService) {}
|
||||
constructor(
|
||||
protected vaultFilterService: DeprecatedVaultFilterService,
|
||||
protected accountService: AccountService,
|
||||
) {}
|
||||
|
||||
get displayCollections() {
|
||||
return this.collections?.fullList != null && this.collections.fullList.length > 0;
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.collapsedFilterNodes = await this.vaultFilterService.buildCollapsedFilterNodes();
|
||||
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.collapsedFilterNodes = await this.vaultFilterService.buildCollapsedFilterNodes(
|
||||
this.activeUserId,
|
||||
);
|
||||
this.organizations = await this.vaultFilterService.buildOrganizations();
|
||||
if (this.organizations != null && this.organizations.length > 0) {
|
||||
this.activeOrganizationDataOwnershipPolicy =
|
||||
@@ -68,7 +79,10 @@ export class VaultFilterComponent implements OnInit {
|
||||
} else {
|
||||
this.collapsedFilterNodes.add(node.id);
|
||||
}
|
||||
await this.vaultFilterService.storeCollapsedFilterNodes(this.collapsedFilterNodes);
|
||||
await this.vaultFilterService.storeCollapsedFilterNodes(
|
||||
this.collapsedFilterNodes,
|
||||
this.activeUserId,
|
||||
);
|
||||
}
|
||||
|
||||
async applyFilter(filter: VaultFilter) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherStatus } from "./cipher-status.model";
|
||||
|
||||
export type VaultFilterFunction = (cipher: CipherView) => boolean;
|
||||
export type VaultFilterFunction = (cipher: CipherViewLike) => boolean;
|
||||
|
||||
export class VaultFilter {
|
||||
cipherType?: CipherType;
|
||||
@@ -44,10 +47,10 @@ export class VaultFilter {
|
||||
cipherPassesFilter = cipher.favorite;
|
||||
}
|
||||
if (this.status === "trash" && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.isDeleted;
|
||||
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
if (this.cipherType != null && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.type === this.cipherType;
|
||||
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
||||
}
|
||||
if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.folderId == null;
|
||||
@@ -68,7 +71,7 @@ export class VaultFilter {
|
||||
cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId;
|
||||
}
|
||||
if (this.myVaultOnly && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.organizationId === null;
|
||||
cipherPassesFilter = cipher.organizationId == null;
|
||||
}
|
||||
return cipherPassesFilter;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
@@ -28,10 +33,9 @@ const NestingDelimiter = "/";
|
||||
|
||||
@Injectable()
|
||||
export class VaultFilterService implements DeprecatedVaultFilterServiceAbstraction {
|
||||
private collapsedGroupingsState: ActiveUserState<string[]> =
|
||||
this.stateProvider.getActive(COLLAPSED_GROUPINGS);
|
||||
private readonly collapsedGroupings$: Observable<Set<string>> =
|
||||
this.collapsedGroupingsState.state$.pipe(map((c) => new Set(c)));
|
||||
private collapsedGroupingsState(userId: UserId): SingleUserState<string[]> {
|
||||
return this.stateProvider.getUser(userId, COLLAPSED_GROUPINGS);
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected organizationService: OrganizationService,
|
||||
@@ -41,14 +45,21 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
protected policyService: PolicyService,
|
||||
protected stateProvider: StateProvider,
|
||||
protected accountService: AccountService,
|
||||
protected configService: ConfigService,
|
||||
protected i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async storeCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
|
||||
await this.collapsedGroupingsState.update(() => Array.from(collapsedFilterNodes));
|
||||
async storeCollapsedFilterNodes(
|
||||
collapsedFilterNodes: Set<string>,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await this.collapsedGroupingsState(userId).update(() => Array.from(collapsedFilterNodes));
|
||||
}
|
||||
|
||||
async buildCollapsedFilterNodes(): Promise<Set<string>> {
|
||||
return await firstValueFrom(this.collapsedGroupings$);
|
||||
async buildCollapsedFilterNodes(userId: UserId): Promise<Set<string>> {
|
||||
return await firstValueFrom(
|
||||
this.collapsedGroupingsState(userId).state$.pipe(map((c) => new Set(c))),
|
||||
);
|
||||
}
|
||||
|
||||
async buildOrganizations(): Promise<Organization[]> {
|
||||
@@ -98,13 +109,26 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
}
|
||||
|
||||
async buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>> {
|
||||
const storedCollections = await this.collectionService.getAllDecrypted();
|
||||
let collections: CollectionView[];
|
||||
if (organizationId != null) {
|
||||
collections = storedCollections.filter((c) => c.organizationId === organizationId);
|
||||
} else {
|
||||
collections = storedCollections;
|
||||
const storedCollections = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
|
||||
),
|
||||
);
|
||||
const orgs = await this.buildOrganizations();
|
||||
const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.CreateDefaultLocation,
|
||||
);
|
||||
|
||||
let collections =
|
||||
organizationId == null
|
||||
? storedCollections
|
||||
: storedCollections.filter((c) => c.organizationId === organizationId);
|
||||
|
||||
if (defaulCollectionsFlagEnabled) {
|
||||
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
|
||||
}
|
||||
|
||||
const nestedCollections = await this.collectionService.getAllNested(collections);
|
||||
return new DynamicTreeNode<CollectionView>({
|
||||
fullList: collections,
|
||||
@@ -141,7 +165,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
folderCopy.id = f.id;
|
||||
folderCopy.revisionDate = f.revisionDate;
|
||||
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter);
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, undefined, NestingDelimiter);
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
@@ -154,3 +178,31 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
return ServiceUtils.getTreeNodeObjectFromList(folders, id) as TreeNode<FolderView>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts collections with default user collections at the top, sorted by organization name.
|
||||
* Remaining collections are sorted by name.
|
||||
* @param collections - The list of collections to sort.
|
||||
* @param orgs - The list of organizations to use for sorting default user collections.
|
||||
* @returns Sorted list of collections.
|
||||
*/
|
||||
export function sortDefaultCollections(
|
||||
collections: CollectionView[],
|
||||
orgs: Organization[] = [],
|
||||
collator: Intl.Collator,
|
||||
): CollectionView[] {
|
||||
const sortedDefaultCollectionTypes = collections
|
||||
.filter((c) => c.type === CollectionTypes.DefaultUserCollection)
|
||||
.sort((a, b) => {
|
||||
const aName = orgs.find((o) => o.id === a.organizationId)?.name ?? a.organizationId;
|
||||
const bName = orgs.find((o) => o.id === b.organizationId)?.name ?? b.organizationId;
|
||||
if (!aName || !bName) {
|
||||
throw new Error("Collection does not have an organizationId.");
|
||||
}
|
||||
return collator.compare(aName, bName);
|
||||
});
|
||||
return [
|
||||
...sortedDefaultCollectionTypes,
|
||||
...collections.filter((c) => c.type !== CollectionTypes.DefaultUserCollection),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import {
|
||||
InputPasswordComponent,
|
||||
InputPasswordFlow,
|
||||
} from "../input-password/input-password.component";
|
||||
import { PasswordInputResult } from "../input-password/password-input-result";
|
||||
|
||||
import { ChangePasswordService } from "./change-password.service.abstraction";
|
||||
|
||||
@Component({
|
||||
selector: "auth-change-password",
|
||||
templateUrl: "change-password.component.html",
|
||||
imports: [InputPasswordComponent, I18nPipe],
|
||||
})
|
||||
export class ChangePasswordComponent implements OnInit {
|
||||
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
|
||||
|
||||
activeAccount: Account | null = null;
|
||||
email?: string;
|
||||
userId?: UserId;
|
||||
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
|
||||
initializing = true;
|
||||
submitting = false;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private changePasswordService: ChangePasswordService,
|
||||
private i18nService: I18nService,
|
||||
private messagingService: MessagingService,
|
||||
private policyService: PolicyService,
|
||||
private toastService: ToastService,
|
||||
private syncService: SyncService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
this.userId = this.activeAccount?.id;
|
||||
this.email = this.activeAccount?.email;
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Error("userId not found");
|
||||
}
|
||||
|
||||
this.masterPasswordPolicyOptions = await firstValueFrom(
|
||||
this.policyService.masterPasswordPolicyOptions$(this.userId),
|
||||
);
|
||||
|
||||
this.initializing = false;
|
||||
}
|
||||
|
||||
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
|
||||
this.submitting = true;
|
||||
|
||||
try {
|
||||
if (passwordInputResult.rotateUserKey) {
|
||||
if (this.activeAccount == null) {
|
||||
throw new Error("activeAccount not found");
|
||||
}
|
||||
|
||||
if (
|
||||
passwordInputResult.currentPassword == null ||
|
||||
passwordInputResult.newPasswordHint == null
|
||||
) {
|
||||
throw new Error("currentPassword or newPasswordHint not found");
|
||||
}
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData(
|
||||
passwordInputResult.currentPassword,
|
||||
passwordInputResult.newPassword,
|
||||
this.activeAccount,
|
||||
passwordInputResult.newPasswordHint,
|
||||
);
|
||||
} else {
|
||||
if (!this.userId) {
|
||||
throw new Error("userId not found");
|
||||
}
|
||||
|
||||
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("masterPasswordChanged"),
|
||||
message: this.i18nService.t("masterPasswordChangedDesc"),
|
||||
});
|
||||
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* This barrel file should only contain Angular exports
|
||||
*/
|
||||
// change password
|
||||
export * from "./change-password/change-password.component";
|
||||
export * from "./change-password/change-password.service.abstraction";
|
||||
export * from "./change-password/default-change-password.service";
|
||||
|
||||
// fingerprint dialog
|
||||
export * from "./fingerprint-dialog/fingerprint-dialog.component";
|
||||
@@ -45,11 +41,6 @@ export * from "./registration/registration-env-selector/registration-env-selecto
|
||||
export * from "./registration/registration-finish/registration-finish.service";
|
||||
export * from "./registration/registration-finish/default-registration-finish.service";
|
||||
|
||||
// set password (JIT user)
|
||||
export * from "./set-password-jit/set-password-jit.component";
|
||||
export * from "./set-password-jit/set-password-jit.service.abstraction";
|
||||
export * from "./set-password-jit/default-set-password-jit.service";
|
||||
|
||||
// user verification
|
||||
export * from "./user-verification/user-verification-dialog.component";
|
||||
export * from "./user-verification/user-verification-dialog.types";
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-mb-6">
|
||||
<bit-form-field>
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "newMasterPass" | i18n }}</bit-label>
|
||||
<input
|
||||
id="input-password-form_new-password"
|
||||
|
||||
@@ -129,7 +129,7 @@ export class InputPasswordComponent implements OnInit {
|
||||
@Input({ transform: (val: string) => val?.trim().toLowerCase() }) email?: string;
|
||||
@Input() userId?: UserId;
|
||||
@Input() loading = false;
|
||||
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
|
||||
@Input() masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
|
||||
|
||||
@Input() inlineButtons = false;
|
||||
@Input() primaryButtonText?: Translation;
|
||||
@@ -169,7 +169,7 @@ export class InputPasswordComponent implements OnInit {
|
||||
|
||||
protected get minPasswordLengthMsg() {
|
||||
if (
|
||||
this.masterPasswordPolicyOptions != null &&
|
||||
this.masterPasswordPolicyOptions != undefined &&
|
||||
this.masterPasswordPolicyOptions.minLength > 0
|
||||
) {
|
||||
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
|
||||
@@ -463,7 +463,7 @@ export class InputPasswordComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Returns `true` if the current password is correct (it can be used to successfully decrypt
|
||||
* the masterKeyEncrypedUserKey), `false` otherwise
|
||||
* the masterKeyEncryptedUserKey), `false` otherwise
|
||||
*/
|
||||
private async verifyCurrentPassword(
|
||||
currentPassword: string,
|
||||
|
||||
@@ -249,7 +249,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
|
||||
}
|
||||
|
||||
try {
|
||||
const { publicKey, privateKey } = await this.keyService.initAccount();
|
||||
const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId);
|
||||
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
|
||||
await this.apiService.postAccountKeys(keysRequest);
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
# Authentication Flows Documentation
|
||||
# Login via Auth Request Documentation
|
||||
|
||||
<br>
|
||||
|
||||
**Table of Contents**
|
||||
|
||||
> - [Standard Auth Request Flows](#standard-auth-request-flows)
|
||||
> - [Admin Auth Request Flow](#admin-auth-request-flow)
|
||||
> - [Summary Table](#summary-table)
|
||||
> - [State Management](#state-management)
|
||||
|
||||
<br>
|
||||
|
||||
## Standard Auth Request Flows
|
||||
|
||||
### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory
|
||||
|
||||
1. Unauthed user clicks "Login with device"
|
||||
2. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
2. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
|
||||
3. Receives approval from a device with authRequestPublicKey(masterKey)
|
||||
4. Decrypts masterKey
|
||||
5. Decrypts userKey
|
||||
@@ -14,7 +25,7 @@
|
||||
### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory
|
||||
|
||||
1. Unauthed user clicks "Login with device"
|
||||
2. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
2. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
|
||||
3. Receives approval from a device with authRequestPublicKey(userKey)
|
||||
4. Decrypts userKey
|
||||
5. Proceeds to vault
|
||||
@@ -34,9 +45,9 @@ get into this flow:
|
||||
### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to /login-initiated
|
||||
2. Navigates to `/login-initiated`
|
||||
3. Clicks "Approve from your other device"
|
||||
4. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
4. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
|
||||
5. Receives approval from device with authRequestPublicKey(masterKey)
|
||||
6. Decrypts masterKey
|
||||
7. Decrypts userKey
|
||||
@@ -46,22 +57,24 @@ get into this flow:
|
||||
### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to /login-initiated
|
||||
2. Navigates to `/login-initiated`
|
||||
3. Clicks "Approve from your other device"
|
||||
4. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
4. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
|
||||
5. Receives approval from device with authRequestPublicKey(userKey)
|
||||
6. Decrypts userKey
|
||||
7. Establishes trust (if required)
|
||||
8. Proceeds to vault
|
||||
|
||||
<br>
|
||||
|
||||
## Admin Auth Request Flow
|
||||
|
||||
### Flow: Authed SSO TD user requests admin approval
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to /login-initiated
|
||||
2. Navigates to `/login-initiated`
|
||||
3. Clicks "Request admin approval"
|
||||
4. Navigates to /admin-approval-requested which creates an AdminAuthRequest
|
||||
4. Navigates to `/admin-approval-requested` which creates an `AdminAuthRequest`
|
||||
5. Receives approval from device with authRequestPublicKey(userKey)
|
||||
6. Decrypts userKey
|
||||
7. Establishes trust (if required)
|
||||
@@ -70,21 +83,25 @@ get into this flow:
|
||||
**Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's
|
||||
userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
|
||||
|
||||
<br>
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
|
||||
| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- |
|
||||
| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
|
||||
| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
|
||||
| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
|
||||
| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
|
||||
| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
|
||||
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
|
||||
| --------------- | ----------- | ----------------------------------------------------- | --------------------------- | ------------------------------------------------- |
|
||||
| Standard Flow 1 | unauthed | "Login with device" [`/login`] | `/login-with-device` | yes |
|
||||
| Standard Flow 2 | unauthed | "Login with device" [`/login`] | `/login-with-device` | no |
|
||||
| Standard Flow 3 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | yes |
|
||||
| Standard Flow 4 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | no |
|
||||
| Admin Flow | authed | "Request admin approval"<br>[`/login-initiated`] | `/admin-approval-requested` | NA - admin requests always send encrypted userKey |
|
||||
|
||||
**Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their
|
||||
account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a
|
||||
master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged
|
||||
into that device but that device does not have masterKey IN MEMORY.
|
||||
|
||||
<br>
|
||||
|
||||
## State Management
|
||||
|
||||
### View Cache
|
||||
@@ -102,6 +119,8 @@ The cache is used to:
|
||||
2. Allow resumption of pending auth requests
|
||||
3. Enable processing of approved requests after extension close and reopen.
|
||||
|
||||
<br>
|
||||
|
||||
### Component State Variables
|
||||
|
||||
Key state variables maintained during the authentication process:
|
||||
@@ -149,6 +168,8 @@ protected flow = Flow.StandardAuthRequest
|
||||
- Affects UI rendering and request handling
|
||||
- Set based on route and authentication state
|
||||
|
||||
<br>
|
||||
|
||||
### State Flow Examples
|
||||
|
||||
#### Standard Auth Request Cache Flow
|
||||
@@ -186,6 +207,8 @@ protected flow = Flow.StandardAuthRequest
|
||||
- Either resumes monitoring or starts new request
|
||||
- Clears state after successful approval
|
||||
|
||||
<br>
|
||||
|
||||
### State Cleanup
|
||||
|
||||
State cleanup occurs in several scenarios:
|
||||
@@ -18,9 +18,11 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -122,6 +124,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private validationService: ValidationService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
}
|
||||
@@ -225,7 +229,21 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = new PasswordLoginCredentials(email, masterPassword);
|
||||
// Try to retrieve any org policies from an org invite now so we can send it to the
|
||||
// login strategies. Since it is optional and we only want to be doing this on the
|
||||
// web we will only send in content in the right context.
|
||||
const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite
|
||||
? await this.loginComponentService.getOrgPoliciesFromOrgInvite()
|
||||
: null;
|
||||
|
||||
const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
|
||||
|
||||
const credentials = new PasswordLoginCredentials(
|
||||
email,
|
||||
masterPassword,
|
||||
undefined,
|
||||
orgMasterPasswordPolicyOptions,
|
||||
);
|
||||
|
||||
try {
|
||||
const authResult = await this.loginStrategyService.logIn(credentials);
|
||||
@@ -284,7 +302,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
This is now unsupported and requires a downgraded client */
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccured"),
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("legacyEncryptionUnsupported"),
|
||||
});
|
||||
return;
|
||||
@@ -305,7 +323,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
|
||||
// Determine where to send the user next
|
||||
// The AuthGuard will handle routing to update-temp-password based on state
|
||||
// The AuthGuard will handle routing to change-password based on state
|
||||
|
||||
// TODO: PM-18269 - evaluate if we can combine this with the
|
||||
// password evaluation done in the password login strategy.
|
||||
@@ -317,7 +335,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
if (orgPolicies) {
|
||||
// Since we have retrieved the policies, we can go ahead and set them into state for future use
|
||||
// e.g., the update-password page currently only references state for policy data and
|
||||
// e.g., the change-password page currently only references state for policy data and
|
||||
// doesn't fallback to pulling them from the server like it should if they are null.
|
||||
await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies);
|
||||
|
||||
@@ -325,7 +343,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
orgPolicies.enforcedPasswordPolicyOptions,
|
||||
);
|
||||
if (isPasswordChangeRequired) {
|
||||
await this.router.navigate(["update-password"]);
|
||||
await this.router.navigate(["change-password"]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -337,9 +355,15 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
await this.router.navigate(["vault"]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the master password meets the enforced policy requirements
|
||||
* and if the user is required to change their password.
|
||||
*
|
||||
* TODO: This is duplicate checking that we want to only do in the password login strategy.
|
||||
* Once we no longer need the policies state being set to reference later in change password
|
||||
* via using the Admin Console's new policy endpoint changes we can remove this. Consult
|
||||
* PM-23001 for details.
|
||||
*/
|
||||
private async isPasswordChangeRequiredByOrgPolicy(
|
||||
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions,
|
||||
|
||||
@@ -2,11 +2,16 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { firstValueFrom, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
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";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
@@ -61,6 +66,9 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -141,8 +149,21 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.loginSuccessHandlerService.run(authResult.userId);
|
||||
|
||||
// If verification succeeds, navigate to vault
|
||||
await this.router.navigate(["/vault"]);
|
||||
// TODO: PM-22663 use the new service to handle routing.
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
|
||||
);
|
||||
|
||||
if (
|
||||
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword ||
|
||||
forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset
|
||||
) {
|
||||
await this.router.navigate(["/change-password"]);
|
||||
} else {
|
||||
await this.router.navigate(["/vault"]);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
let errorMessage =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { PasswordInputResult } from "../../input-password/password-input-result";
|
||||
|
||||
@@ -10,7 +10,9 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { RegisterVerificationEmailClickedRequest } from "@bitwarden/common/auth/models/request/registration/register-verification-email-clicked.request";
|
||||
import { HttpStatusCode } from "@bitwarden/common/enums";
|
||||
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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
@@ -77,6 +79,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -186,11 +189,18 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("youHaveBeenLoggedIn"),
|
||||
});
|
||||
const endUserActivationFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM19315EndUserActivationMvp,
|
||||
);
|
||||
|
||||
if (!endUserActivationFlagEnabled) {
|
||||
// Only show the toast when the end user activation feature flag is _not_ enabled
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("youHaveBeenLoggedIn"),
|
||||
});
|
||||
}
|
||||
|
||||
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
FakeUserDecryptionOptions as UserDecryptionOptions,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { PasswordInputResult } from "../input-password/password-input-result";
|
||||
|
||||
import { DefaultSetPasswordJitService } from "./default-set-password-jit.service";
|
||||
import { SetPasswordCredentials } from "./set-password-jit.service.abstraction";
|
||||
|
||||
describe("DefaultSetPasswordJitService", () => {
|
||||
let sut: DefaultSetPasswordJitService;
|
||||
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
masterPasswordApiService = mock<MasterPasswordApiService>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
i18nService = mock<I18nService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
organizationUserApiService = mock<OrganizationUserApiService>();
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
|
||||
sut = new DefaultSetPasswordJitService(
|
||||
apiService,
|
||||
masterPasswordApiService,
|
||||
keyService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
kdfConfigService,
|
||||
masterPasswordService,
|
||||
organizationApiService,
|
||||
organizationUserApiService,
|
||||
userDecryptionOptionsService,
|
||||
);
|
||||
});
|
||||
|
||||
it("should instantiate the DefaultSetPasswordJitService", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("setPassword", () => {
|
||||
let masterKey: MasterKey;
|
||||
let userKey: UserKey;
|
||||
let userKeyEncString: EncString;
|
||||
let protectedUserKey: [UserKey, EncString];
|
||||
let keyPair: [string, EncString];
|
||||
let keysRequest: KeysRequest;
|
||||
let organizationKeys: OrganizationKeysResponse;
|
||||
let orgPublicKey: Uint8Array;
|
||||
|
||||
let orgSsoIdentifier: string;
|
||||
let orgId: string;
|
||||
let resetPasswordAutoEnroll: boolean;
|
||||
let userId: UserId;
|
||||
let passwordInputResult: PasswordInputResult;
|
||||
let credentials: SetPasswordCredentials;
|
||||
|
||||
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
|
||||
let setPasswordRequest: SetPasswordRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
|
||||
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
|
||||
userKeyEncString = new EncString("userKeyEncrypted");
|
||||
protectedUserKey = [userKey, userKeyEncString];
|
||||
keyPair = ["publicKey", new EncString("privateKey")];
|
||||
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
|
||||
organizationKeys = {
|
||||
privateKey: "orgPrivateKey",
|
||||
publicKey: "orgPublicKey",
|
||||
} as OrganizationKeysResponse;
|
||||
orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
|
||||
|
||||
orgSsoIdentifier = "orgSsoIdentifier";
|
||||
orgId = "orgId";
|
||||
resetPasswordAutoEnroll = false;
|
||||
userId = "userId" as UserId;
|
||||
|
||||
passwordInputResult = {
|
||||
newMasterKey: masterKey,
|
||||
newServerMasterKeyHash: "newServerMasterKeyHash",
|
||||
newLocalMasterKeyHash: "newLocalMasterKeyHash",
|
||||
newPasswordHint: "newPasswordHint",
|
||||
kdfConfig: DEFAULT_KDF_CONFIG,
|
||||
newPassword: "newPassword",
|
||||
};
|
||||
|
||||
credentials = {
|
||||
newMasterKey: passwordInputResult.newMasterKey,
|
||||
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
|
||||
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
|
||||
newPasswordHint: passwordInputResult.newPasswordHint,
|
||||
kdfConfig: passwordInputResult.kdfConfig,
|
||||
orgSsoIdentifier,
|
||||
orgId,
|
||||
resetPasswordAutoEnroll,
|
||||
userId,
|
||||
};
|
||||
|
||||
userDecryptionOptionsSubject = new BehaviorSubject(null);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
|
||||
|
||||
setPasswordRequest = new SetPasswordRequest(
|
||||
passwordInputResult.newServerMasterKeyHash,
|
||||
protectedUserKey[1].encryptedString,
|
||||
passwordInputResult.newPasswordHint,
|
||||
orgSsoIdentifier,
|
||||
keysRequest,
|
||||
passwordInputResult.kdfConfig.kdfType,
|
||||
passwordInputResult.kdfConfig.iterations,
|
||||
);
|
||||
});
|
||||
|
||||
function setupSetPasswordMocks(hasUserKey = true) {
|
||||
if (!hasUserKey) {
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
keyService.makeUserKey.mockResolvedValue(protectedUserKey);
|
||||
} else {
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(protectedUserKey);
|
||||
}
|
||||
|
||||
keyService.makeKeyPair.mockResolvedValue(keyPair);
|
||||
|
||||
masterPasswordApiService.setPassword.mockResolvedValue(undefined);
|
||||
masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined);
|
||||
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
userDecryptionOptionsService.setUserDecryptionOptions.mockResolvedValue(undefined);
|
||||
kdfConfigService.setKdfConfig.mockResolvedValue(undefined);
|
||||
keyService.setUserKey.mockResolvedValue(undefined);
|
||||
|
||||
keyService.setPrivateKey.mockResolvedValue(undefined);
|
||||
|
||||
masterPasswordService.setMasterKeyHash.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
function setupResetPasswordAutoEnrollMocks(organizationKeysExist = true) {
|
||||
if (organizationKeysExist) {
|
||||
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
|
||||
} else {
|
||||
organizationApiService.getKeys.mockResolvedValue(null);
|
||||
return;
|
||||
}
|
||||
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(userKeyEncString);
|
||||
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
it("should set password successfully (given a user key)", async () => {
|
||||
// Arrange
|
||||
setupSetPasswordMocks();
|
||||
|
||||
// Act
|
||||
await sut.setPassword(credentials);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
|
||||
it("should set password successfully (given no user key)", async () => {
|
||||
// Arrange
|
||||
setupSetPasswordMocks(false);
|
||||
|
||||
// Act
|
||||
await sut.setPassword(credentials);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
});
|
||||
|
||||
it("should handle reset password auto enroll", async () => {
|
||||
// Arrange
|
||||
credentials.resetPasswordAutoEnroll = true;
|
||||
|
||||
setupSetPasswordMocks();
|
||||
setupResetPasswordAutoEnrollMocks();
|
||||
|
||||
// Act
|
||||
await sut.setPassword(credentials);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
|
||||
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(userKey, orgPublicKey);
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("when handling reset password auto enroll, it should throw an error if organization keys are not found", async () => {
|
||||
// Arrange
|
||||
credentials.resetPasswordAutoEnroll = true;
|
||||
|
||||
setupSetPasswordMocks();
|
||||
setupResetPasswordAutoEnrollMocks(false);
|
||||
|
||||
// Act and Assert
|
||||
await expect(sut.setPassword(credentials)).rejects.toThrow();
|
||||
expect(
|
||||
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordEnrollmentRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
SetPasswordCredentials,
|
||||
SetPasswordJitService,
|
||||
} from "./set-password-jit.service.abstraction";
|
||||
|
||||
export class DefaultSetPasswordJitService implements SetPasswordJitService {
|
||||
constructor(
|
||||
protected apiService: ApiService,
|
||||
protected masterPasswordApiService: MasterPasswordApiService,
|
||||
protected keyService: KeyService,
|
||||
protected encryptService: EncryptService,
|
||||
protected i18nService: I18nService,
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected organizationUserApiService: OrganizationUserApiService,
|
||||
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async setPassword(credentials: SetPasswordCredentials): Promise<void> {
|
||||
const {
|
||||
newMasterKey,
|
||||
newServerMasterKeyHash,
|
||||
newLocalMasterKeyHash,
|
||||
newPasswordHint,
|
||||
kdfConfig,
|
||||
orgSsoIdentifier,
|
||||
orgId,
|
||||
resetPasswordAutoEnroll,
|
||||
userId,
|
||||
} = credentials;
|
||||
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value == null) {
|
||||
throw new Error(`${key} not found. Could not set password.`);
|
||||
}
|
||||
}
|
||||
|
||||
const protectedUserKey = await this.makeProtectedUserKey(newMasterKey, userId);
|
||||
if (protectedUserKey == null) {
|
||||
throw new Error("protectedUserKey not found. Could not set password.");
|
||||
}
|
||||
|
||||
// Since this is an existing JIT provisioned user in a MP encryption org setting first password,
|
||||
// they will not already have a user asymmetric key pair so we must create it for them.
|
||||
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
|
||||
|
||||
const request = new SetPasswordRequest(
|
||||
newServerMasterKeyHash,
|
||||
protectedUserKey[1].encryptedString,
|
||||
newPasswordHint,
|
||||
orgSsoIdentifier,
|
||||
keysRequest,
|
||||
kdfConfig.kdfType,
|
||||
kdfConfig.iterations,
|
||||
);
|
||||
|
||||
await this.masterPasswordApiService.setPassword(request);
|
||||
|
||||
// Clear force set password reason to allow navigation back to vault.
|
||||
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||
|
||||
// User now has a password so update account decryption options in state
|
||||
await this.updateAccountDecryptionProperties(newMasterKey, kdfConfig, protectedUserKey, userId);
|
||||
|
||||
await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId);
|
||||
|
||||
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
|
||||
|
||||
if (resetPasswordAutoEnroll) {
|
||||
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async makeProtectedUserKey(
|
||||
masterKey: MasterKey,
|
||||
userId: UserId,
|
||||
): Promise<[UserKey, EncString]> {
|
||||
let protectedUserKey: [UserKey, EncString] = null;
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
if (userKey == null) {
|
||||
protectedUserKey = await this.keyService.makeUserKey(masterKey);
|
||||
} else {
|
||||
protectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey);
|
||||
}
|
||||
|
||||
return protectedUserKey;
|
||||
}
|
||||
|
||||
private async makeKeyPairAndRequest(
|
||||
protectedUserKey: [UserKey, EncString],
|
||||
): Promise<[[string, EncString], KeysRequest]> {
|
||||
const keyPair = await this.keyService.makeKeyPair(protectedUserKey[0]);
|
||||
if (keyPair == null) {
|
||||
throw new Error("keyPair not found. Could not set password.");
|
||||
}
|
||||
const keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
|
||||
|
||||
return [keyPair, keysRequest];
|
||||
}
|
||||
|
||||
private async updateAccountDecryptionProperties(
|
||||
masterKey: MasterKey,
|
||||
kdfConfig: KdfConfig,
|
||||
protectedUserKey: [UserKey, EncString],
|
||||
userId: UserId,
|
||||
) {
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
userDecryptionOpts.hasMasterPassword = true;
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
|
||||
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
await this.keyService.setUserKey(protectedUserKey[0], userId);
|
||||
}
|
||||
|
||||
private async handleResetPasswordAutoEnroll(
|
||||
masterKeyHash: string,
|
||||
orgId: string,
|
||||
userId: UserId,
|
||||
) {
|
||||
const organizationKeys = await this.organizationApiService.getKeys(orgId);
|
||||
|
||||
if (organizationKeys == null) {
|
||||
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
|
||||
}
|
||||
|
||||
const publicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
|
||||
|
||||
// RSA Encrypt user key with organization public key
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
if (userKey == null) {
|
||||
throw new Error("userKey not found. Could not handle reset password auto enroll.");
|
||||
}
|
||||
|
||||
const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(userKey, publicKey);
|
||||
|
||||
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
resetRequest.masterPasswordHash = masterKeyHash;
|
||||
resetRequest.resetPasswordKey = encryptedUserKey.encryptedString;
|
||||
|
||||
await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
|
||||
orgId,
|
||||
userId,
|
||||
resetRequest,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<ng-container *ngIf="syncLoading">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-mr-2" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
{{ "loading" | i18n }}
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!syncLoading">
|
||||
<app-callout
|
||||
type="warning"
|
||||
title="{{ 'resetPasswordPolicyAutoEnroll' | i18n }}"
|
||||
*ngIf="resetPasswordAutoEnroll"
|
||||
>
|
||||
{{ "resetPasswordAutoEnrollInviteWarning" | i18n }}
|
||||
</app-callout>
|
||||
|
||||
<auth-input-password
|
||||
[flow]="inputPasswordFlow"
|
||||
[email]="email"
|
||||
[userId]="userId"
|
||||
[loading]="submitting"
|
||||
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
|
||||
[primaryButtonText]="{ key: 'createAccount' }"
|
||||
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
|
||||
></auth-input-password>
|
||||
</ng-container>
|
||||
@@ -1,135 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ToastService } from "../../../../components/src/toast";
|
||||
import {
|
||||
InputPasswordComponent,
|
||||
InputPasswordFlow,
|
||||
} from "../input-password/input-password.component";
|
||||
import { PasswordInputResult } from "../input-password/password-input-result";
|
||||
|
||||
import {
|
||||
SetPasswordCredentials,
|
||||
SetPasswordJitService,
|
||||
} from "./set-password-jit.service.abstraction";
|
||||
|
||||
@Component({
|
||||
selector: "auth-set-password-jit",
|
||||
templateUrl: "set-password-jit.component.html",
|
||||
imports: [CommonModule, InputPasswordComponent, JslibModule],
|
||||
})
|
||||
export class SetPasswordJitComponent implements OnInit {
|
||||
protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser;
|
||||
protected email: string;
|
||||
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
|
||||
protected orgId: string;
|
||||
protected orgSsoIdentifier: string;
|
||||
protected resetPasswordAutoEnroll: boolean;
|
||||
protected submitting = false;
|
||||
protected syncLoading = true;
|
||||
protected userId: UserId;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private i18nService: I18nService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private router: Router,
|
||||
private setPasswordJitService: SetPasswordJitService,
|
||||
private syncService: SyncService,
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
this.userId = activeAccount?.id;
|
||||
this.email = activeAccount?.email;
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
this.syncLoading = false;
|
||||
|
||||
await this.handleQueryParams();
|
||||
}
|
||||
|
||||
private async handleQueryParams() {
|
||||
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
|
||||
|
||||
if (qParams.identifier != null) {
|
||||
try {
|
||||
this.orgSsoIdentifier = qParams.identifier;
|
||||
|
||||
const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus(
|
||||
this.orgSsoIdentifier,
|
||||
);
|
||||
this.orgId = autoEnrollStatus.id;
|
||||
this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled;
|
||||
this.masterPasswordPolicyOptions =
|
||||
await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(autoEnrollStatus.id);
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
|
||||
this.submitting = true;
|
||||
|
||||
const credentials: SetPasswordCredentials = {
|
||||
newMasterKey: passwordInputResult.newMasterKey,
|
||||
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
|
||||
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
|
||||
newPasswordHint: passwordInputResult.newPasswordHint,
|
||||
kdfConfig: passwordInputResult.kdfConfig,
|
||||
orgSsoIdentifier: this.orgSsoIdentifier,
|
||||
orgId: this.orgId,
|
||||
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
|
||||
userId: this.userId,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.setPasswordJitService.setPassword(credentials);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("accountSuccessfullyCreated"),
|
||||
});
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("inviteAccepted"),
|
||||
});
|
||||
|
||||
this.submitting = false;
|
||||
|
||||
await this.router.navigate(["vault"]);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
export interface SetPasswordCredentials {
|
||||
newMasterKey: MasterKey;
|
||||
newServerMasterKeyHash: string;
|
||||
newLocalMasterKeyHash: string;
|
||||
newPasswordHint: string;
|
||||
kdfConfig: KdfConfig;
|
||||
orgSsoIdentifier: string;
|
||||
orgId: string;
|
||||
resetPasswordAutoEnroll: boolean;
|
||||
userId: UserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* This service handles setting a password for a "just-in-time" provisioned user.
|
||||
*
|
||||
* A "just-in-time" (JIT) provisioned user is a user who does not have a registered account at the
|
||||
* time they first click "Login with SSO". Once they click "Login with SSO" we register the account on
|
||||
* the fly ("just-in-time").
|
||||
*/
|
||||
export abstract class SetPasswordJitService {
|
||||
/**
|
||||
* Sets the password for a JIT provisioned user.
|
||||
*
|
||||
* @param credentials An object of the credentials needed to set the password for a JIT provisioned user
|
||||
* @throws If any property on the `credentials` object is null or undefined, or if a protectedUserKey
|
||||
* or newKeyPair could not be created.
|
||||
*/
|
||||
setPassword: (credentials: SetPasswordCredentials) => Promise<void>;
|
||||
}
|
||||
@@ -23,12 +23,10 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -118,7 +116,6 @@ export class SsoComponent implements OnInit {
|
||||
private toastService: ToastService,
|
||||
private ssoComponentService: SsoComponentService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
|
||||
this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html";
|
||||
@@ -534,11 +531,7 @@ export class SsoComponent implements OnInit {
|
||||
}
|
||||
|
||||
private async handleChangePasswordRequired(orgIdentifier: string) {
|
||||
const isSetInitialPasswordRefactorFlagOn = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM16117_SetInitialPasswordRefactor,
|
||||
);
|
||||
const route = isSetInitialPasswordRefactorFlagOn ? "set-initial-password" : "set-password-jit";
|
||||
|
||||
const route = "set-initial-password";
|
||||
await this.router.navigate([route], {
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./two-factor-auth-email";
|
||||
export * from "./two-factor-auth-duo";
|
||||
export * from "./two-factor-auth-webauthn";
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
|
||||
|
||||
export class DefaultTwoFactorAuthEmailComponentService
|
||||
implements TwoFactorAuthEmailComponentService {
|
||||
// no default implementation
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./default-two-factor-auth-email-component.service";
|
||||
export * from "./two-factor-auth-email-component.service";
|
||||
@@ -1,165 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import {
|
||||
TwoFactorAuthEmailComponentCache,
|
||||
TwoFactorAuthEmailComponentCacheService,
|
||||
} from "./two-factor-auth-email-component-cache.service";
|
||||
|
||||
describe("TwoFactorAuthEmailCache", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("returns null when input is null", () => {
|
||||
const result = TwoFactorAuthEmailComponentCache.fromJSON(null as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a TwoFactorAuthEmailCache instance from valid JSON", () => {
|
||||
const jsonData = { emailSent: true };
|
||||
const result = TwoFactorAuthEmailComponentCache.fromJSON(jsonData);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(TwoFactorAuthEmailComponentCache);
|
||||
expect(result?.emailSent).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwoFactorAuthEmailComponentCacheService", () => {
|
||||
let service: TwoFactorAuthEmailComponentCacheService;
|
||||
let mockViewCacheService: MockProxy<ViewCacheService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let cacheData: BehaviorSubject<TwoFactorAuthEmailComponentCache | null>;
|
||||
let mockSignal: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewCacheService = mock<ViewCacheService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
cacheData = new BehaviorSubject<TwoFactorAuthEmailComponentCache | null>(null);
|
||||
mockSignal = jest.fn(() => cacheData.getValue());
|
||||
mockSignal.set = jest.fn((value: TwoFactorAuthEmailComponentCache | null) =>
|
||||
cacheData.next(value),
|
||||
);
|
||||
mockViewCacheService.signal.mockReturnValue(mockSignal);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TwoFactorAuthEmailComponentCacheService,
|
||||
{ provide: ViewCacheService, useValue: mockViewCacheService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(TwoFactorAuthEmailComponentCacheService);
|
||||
});
|
||||
|
||||
it("creates the service", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("sets featureEnabled to true when flag is enabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
expect(mockSignal.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets featureEnabled to false when flag is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cacheData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("caches email sent state when feature is enabled", () => {
|
||||
service.cacheData({ emailSent: true });
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith({
|
||||
emailSent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("clears cached data when feature is enabled", () => {
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not clear cached data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("returns cached data when feature is enabled", () => {
|
||||
const testData = new TwoFactorAuthEmailComponentCache();
|
||||
testData.emailSent = true;
|
||||
cacheData.next(testData);
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
expect(mockSignal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user