mirror of
https://github.com/bitwarden/browser
synced 2026-02-20 11:24:07 +00:00
Merge branch 'main' into km/test-drop-encrypted-object
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
|
||||
|
||||
export abstract class CollectionAdminService {
|
||||
abstract getAll(organizationId: string): Promise<CollectionAdminView[]>;
|
||||
abstract get(
|
||||
abstract collectionAdminViews$(
|
||||
organizationId: string,
|
||||
collectionId: string,
|
||||
): Promise<CollectionAdminView | undefined>;
|
||||
userId: UserId,
|
||||
): Observable<CollectionAdminView[]>;
|
||||
abstract save(
|
||||
collection: CollectionAdminView,
|
||||
userId: UserId,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
|
||||
import { CollectionAccessDetailsResponse } from "./collection.response";
|
||||
import { CollectionAccessDetailsResponse, CollectionResponse } from "./collection.response";
|
||||
import { CollectionView } from "./collection.view";
|
||||
|
||||
// TODO: this is used to represent the pseudo "Unassigned" collection as well as
|
||||
// the user's personal vault (as a pseudo organization). This should be separated out into different values.
|
||||
export const Unassigned = "unassigned";
|
||||
export type Unassigned = typeof Unassigned;
|
||||
|
||||
export class CollectionAdminView extends CollectionView {
|
||||
groups: CollectionAccessSelectionView[] = [];
|
||||
@@ -21,24 +27,6 @@ export class CollectionAdminView extends CollectionView {
|
||||
*/
|
||||
assigned: boolean = false;
|
||||
|
||||
constructor(response?: CollectionAccessDetailsResponse) {
|
||||
super(response);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.groups = response.groups
|
||||
? response.groups.map((g) => new CollectionAccessSelectionView(g))
|
||||
: [];
|
||||
|
||||
this.users = response.users
|
||||
? response.users.map((g) => new CollectionAccessSelectionView(g))
|
||||
: [];
|
||||
|
||||
this.assigned = response.assigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
|
||||
*/
|
||||
@@ -112,4 +100,46 @@ export class CollectionAdminView extends CollectionView {
|
||||
get isUnassignedCollection() {
|
||||
return this.id === Unassigned;
|
||||
}
|
||||
|
||||
static async fromCollectionAccessDetails(
|
||||
collection: CollectionAccessDetailsResponse,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<CollectionAdminView> {
|
||||
const view = new CollectionAdminView({ ...collection });
|
||||
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
|
||||
view.assigned = collection.assigned;
|
||||
view.readOnly = collection.readOnly;
|
||||
view.hidePasswords = collection.hidePasswords;
|
||||
view.manage = collection.manage;
|
||||
view.unmanaged = collection.unmanaged;
|
||||
view.type = collection.type;
|
||||
view.externalId = collection.externalId;
|
||||
|
||||
view.groups = collection.groups
|
||||
? collection.groups.map((g) => new CollectionAccessSelectionView(g))
|
||||
: [];
|
||||
|
||||
view.users = collection.users
|
||||
? collection.users.map((g) => new CollectionAccessSelectionView(g))
|
||||
: [];
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
static async fromCollectionResponse(
|
||||
collection: CollectionResponse,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<CollectionAdminView> {
|
||||
const collectionAdminView = new CollectionAdminView({
|
||||
id: collection.id,
|
||||
name: await encryptService.decryptString(new EncString(collection.name), orgKey),
|
||||
organizationId: collection.organizationId,
|
||||
});
|
||||
|
||||
collectionAdminView.externalId = collection.externalId;
|
||||
|
||||
return collectionAdminView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ export class CollectionWithIdRequest extends CollectionRequest {
|
||||
if (collection == null) {
|
||||
return;
|
||||
}
|
||||
super(collection);
|
||||
super({
|
||||
name: collection.name,
|
||||
externalId: collection.externalId,
|
||||
});
|
||||
this.id = collection.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,18 @@ import { Jsonify } from "type-fest";
|
||||
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { CollectionType } from "./collection";
|
||||
import { CollectionType, CollectionTypes } from "./collection";
|
||||
import { CollectionDetailsResponse } from "./collection.response";
|
||||
|
||||
export class CollectionData {
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
name: string;
|
||||
externalId: string;
|
||||
readOnly: boolean;
|
||||
manage: boolean;
|
||||
hidePasswords: boolean;
|
||||
type: CollectionType;
|
||||
externalId: string | undefined;
|
||||
readOnly: boolean = false;
|
||||
manage: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(response: CollectionDetailsResponse) {
|
||||
this.id = response.id;
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
||||
|
||||
import { Collection } from "./collection";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
|
||||
export class CollectionRequest {
|
||||
name: string;
|
||||
externalId: string;
|
||||
externalId: string | undefined;
|
||||
groups: SelectionReadOnlyRequest[] = [];
|
||||
users: SelectionReadOnlyRequest[] = [];
|
||||
|
||||
constructor(collection?: Collection) {
|
||||
if (collection == null) {
|
||||
return;
|
||||
constructor(c: {
|
||||
name: EncString;
|
||||
users?: SelectionReadOnlyRequest[];
|
||||
groups?: SelectionReadOnlyRequest[];
|
||||
externalId?: string;
|
||||
}) {
|
||||
if (!c.name || !c.name.encryptedString) {
|
||||
throw new Error("Name not provided for CollectionRequest.");
|
||||
}
|
||||
|
||||
this.name = c.name.encryptedString;
|
||||
this.externalId = c.externalId;
|
||||
|
||||
if (c.groups) {
|
||||
this.groups = c.groups;
|
||||
}
|
||||
if (c.users) {
|
||||
this.users = c.users;
|
||||
}
|
||||
this.name = collection.name ? collection.name.encryptedString : null;
|
||||
this.externalId = collection.externalId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/model
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { CollectionType } from "./collection";
|
||||
import { CollectionType, CollectionTypes } from "./collection";
|
||||
|
||||
export class CollectionResponse extends BaseResponse {
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
name: string;
|
||||
externalId: string;
|
||||
type: CollectionType;
|
||||
externalId: string | undefined;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -17,7 +17,7 @@ export class CollectionResponse extends BaseResponse {
|
||||
this.organizationId = this.getResponseProperty("OrganizationId");
|
||||
this.name = this.getResponseProperty("Name");
|
||||
this.externalId = this.getResponseProperty("ExternalId");
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,62 @@
|
||||
import { makeSymmetricCryptoKey, mockEnc } from "@bitwarden/common/spec";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { makeSymmetricCryptoKey } from "@bitwarden/common/spec";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { Collection, CollectionTypes } from "./collection";
|
||||
import { CollectionData } from "./collection.data";
|
||||
import { CollectionDetailsResponse } from "./collection.response";
|
||||
|
||||
describe("Collection", () => {
|
||||
let data: CollectionData;
|
||||
let encService: MockProxy<EncryptService>;
|
||||
|
||||
beforeEach(() => {
|
||||
data = {
|
||||
id: "id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
name: "encName",
|
||||
externalId: "extId",
|
||||
readOnly: true,
|
||||
manage: true,
|
||||
hidePasswords: true,
|
||||
type: CollectionTypes.DefaultUserCollection,
|
||||
};
|
||||
data = new CollectionData(
|
||||
new CollectionDetailsResponse({
|
||||
id: "id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
name: "encName",
|
||||
externalId: "extId",
|
||||
readOnly: true,
|
||||
manage: true,
|
||||
hidePasswords: true,
|
||||
type: CollectionTypes.DefaultUserCollection,
|
||||
}),
|
||||
);
|
||||
encService = mock<EncryptService>();
|
||||
encService.decryptString.mockResolvedValue("encName");
|
||||
});
|
||||
|
||||
it("Convert from empty", () => {
|
||||
const data = new CollectionData({} as any);
|
||||
const card = new Collection(data);
|
||||
|
||||
expect(card).toEqual({
|
||||
externalId: null,
|
||||
hidePasswords: null,
|
||||
id: null,
|
||||
name: null,
|
||||
organizationId: null,
|
||||
readOnly: null,
|
||||
manage: null,
|
||||
type: null,
|
||||
it("Convert from partial", () => {
|
||||
const card = new Collection({
|
||||
name: new EncString("name"),
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: "id" as CollectionId,
|
||||
});
|
||||
expect(() => card).not.toThrow();
|
||||
|
||||
expect(card.name).not.toBe(null);
|
||||
expect(card.organizationId).not.toBe(null);
|
||||
expect(card.id).not.toBe(null);
|
||||
expect(card.externalId).toBe(undefined);
|
||||
expect(card.readOnly).toBe(false);
|
||||
expect(card.manage).toBe(false);
|
||||
expect(card.hidePasswords).toBe(false);
|
||||
expect(card.type).toEqual(CollectionTypes.SharedCollection);
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
const collection = new Collection(data);
|
||||
const collection = Collection.fromCollectionData(data);
|
||||
|
||||
expect(collection).toEqual({
|
||||
id: "id",
|
||||
organizationId: "orgId",
|
||||
name: { encryptedString: "encName", encryptionType: 0 },
|
||||
externalId: { encryptedString: "extId", encryptionType: 0 },
|
||||
externalId: "extId",
|
||||
readOnly: true,
|
||||
manage: true,
|
||||
hidePasswords: true,
|
||||
@@ -53,10 +65,11 @@ describe("Collection", () => {
|
||||
});
|
||||
|
||||
it("Decrypt", async () => {
|
||||
const collection = new Collection();
|
||||
collection.id = "id";
|
||||
collection.organizationId = "orgId" as OrganizationId;
|
||||
collection.name = mockEnc("encName");
|
||||
const collection = new Collection({
|
||||
name: new EncString("encName"),
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: "id" as CollectionId,
|
||||
});
|
||||
collection.externalId = "extId";
|
||||
collection.readOnly = false;
|
||||
collection.hidePasswords = false;
|
||||
@@ -65,7 +78,7 @@ describe("Collection", () => {
|
||||
|
||||
const key = makeSymmetricCryptoKey<OrgKey>();
|
||||
|
||||
const view = await collection.decrypt(key);
|
||||
const view = await collection.decrypt(key, encService);
|
||||
|
||||
expect(view).toEqual({
|
||||
externalId: "extId",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import Domain, { EncryptableKeys } from "@bitwarden/common/platform/models/domain/domain-base";
|
||||
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { CollectionData } from "./collection.data";
|
||||
@@ -13,45 +15,68 @@ export const CollectionTypes = {
|
||||
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
|
||||
|
||||
export class Collection extends Domain {
|
||||
id: string | undefined;
|
||||
organizationId: string | undefined;
|
||||
name: EncString | undefined;
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
name: EncString;
|
||||
externalId: string | undefined;
|
||||
readOnly: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
manage: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(obj?: CollectionData | null) {
|
||||
constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
return;
|
||||
this.id = c.id;
|
||||
this.name = c.name;
|
||||
this.organizationId = c.organizationId;
|
||||
}
|
||||
|
||||
static fromCollectionData(obj: CollectionData): Collection {
|
||||
if (obj == null || obj.name == null || obj.organizationId == null) {
|
||||
throw new Error("CollectionData must contain name and organizationId.");
|
||||
}
|
||||
|
||||
this.buildDomainModel(
|
||||
this,
|
||||
obj,
|
||||
{
|
||||
id: null,
|
||||
organizationId: null,
|
||||
name: null,
|
||||
externalId: null,
|
||||
readOnly: null,
|
||||
hidePasswords: null,
|
||||
manage: null,
|
||||
type: null,
|
||||
},
|
||||
["id", "organizationId", "readOnly", "hidePasswords", "manage", "type"],
|
||||
);
|
||||
const collection = new Collection({
|
||||
...obj,
|
||||
name: new EncString(obj.name),
|
||||
});
|
||||
|
||||
collection.externalId = obj.externalId;
|
||||
collection.readOnly = obj.readOnly;
|
||||
collection.hidePasswords = obj.hidePasswords;
|
||||
collection.manage = obj.manage;
|
||||
collection.type = obj.type;
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
decrypt(orgKey: OrgKey): Promise<CollectionView> {
|
||||
return this.decryptObj<Collection, CollectionView>(
|
||||
this,
|
||||
new CollectionView(this),
|
||||
["name"] as EncryptableKeys<Collection, CollectionView>[],
|
||||
this.organizationId ?? null,
|
||||
orgKey,
|
||||
);
|
||||
static async fromCollectionView(
|
||||
view: CollectionView,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<Collection> {
|
||||
const collection = new Collection({
|
||||
name: await encryptService.encryptString(view.name, orgKey),
|
||||
id: view.id,
|
||||
organizationId: view.organizationId,
|
||||
});
|
||||
|
||||
collection.externalId = view.externalId;
|
||||
collection.readOnly = view.readOnly;
|
||||
collection.hidePasswords = view.hidePasswords;
|
||||
collection.manage = view.manage;
|
||||
collection.type = view.type;
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
decrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<CollectionView> {
|
||||
return CollectionView.fromCollection(this, encryptService, orgKey);
|
||||
}
|
||||
|
||||
// @TODO: This would be better off in Collection.Utils. Move this there when
|
||||
// refactoring to a shared lib.
|
||||
static isCollectionId(id: any): id is CollectionId {
|
||||
return typeof id === "string" && id != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { Collection, CollectionType, CollectionTypes } from "./collection";
|
||||
@@ -10,9 +14,9 @@ import { CollectionAccessDetailsResponse } from "./collection.response";
|
||||
export const NestingDelimiter = "/";
|
||||
|
||||
export class CollectionView implements View, ITreeNodeObject {
|
||||
id: string | undefined;
|
||||
organizationId: string | undefined;
|
||||
name: string = "";
|
||||
id: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
name: string;
|
||||
externalId: string | undefined;
|
||||
// readOnly applies to the items within a collection
|
||||
readOnly: boolean = false;
|
||||
@@ -21,24 +25,10 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
assigned: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(c?: Collection | CollectionAccessDetailsResponse) {
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) {
|
||||
this.id = c.id;
|
||||
this.organizationId = c.organizationId;
|
||||
this.externalId = c.externalId;
|
||||
if (c instanceof Collection) {
|
||||
this.readOnly = c.readOnly;
|
||||
this.hidePasswords = c.hidePasswords;
|
||||
this.manage = c.manage;
|
||||
this.assigned = true;
|
||||
}
|
||||
if (c instanceof CollectionAccessDetailsResponse) {
|
||||
this.assigned = c.assigned;
|
||||
}
|
||||
this.type = c.type;
|
||||
this.name = c.name;
|
||||
}
|
||||
|
||||
canEditItems(org: Organization): boolean {
|
||||
@@ -93,11 +83,56 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
return false;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<CollectionView>) {
|
||||
return Object.assign(new CollectionView(new Collection()), obj);
|
||||
}
|
||||
|
||||
get isDefaultCollection() {
|
||||
return this.type == CollectionTypes.DefaultUserCollection;
|
||||
}
|
||||
|
||||
// FIXME: we should not use a CollectionView object for the vault filter header because it is not a real
|
||||
// CollectionView and this violates ts-strict rules.
|
||||
static vaultFilterHead(): CollectionView {
|
||||
return new CollectionView({
|
||||
id: "" as CollectionId,
|
||||
organizationId: "" as OrganizationId,
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
|
||||
static async fromCollection(
|
||||
collection: Collection,
|
||||
encryptService: EncryptService,
|
||||
key: OrgKey,
|
||||
): Promise<CollectionView> {
|
||||
const view = new CollectionView({ ...collection, name: "" });
|
||||
|
||||
view.name = await encryptService.decryptString(collection.name, key);
|
||||
view.assigned = true;
|
||||
view.externalId = collection.externalId;
|
||||
view.readOnly = collection.readOnly;
|
||||
view.hidePasswords = collection.hidePasswords;
|
||||
view.manage = collection.manage;
|
||||
view.type = collection.type;
|
||||
return view;
|
||||
}
|
||||
|
||||
static async fromCollectionAccessDetails(
|
||||
collection: CollectionAccessDetailsResponse,
|
||||
encryptService: EncryptService,
|
||||
orgKey: OrgKey,
|
||||
): Promise<CollectionView> {
|
||||
const view = new CollectionView({ ...collection });
|
||||
|
||||
view.name = await encryptService.decryptString(new EncString(collection.name), orgKey);
|
||||
view.externalId = collection.externalId;
|
||||
view.type = collection.type;
|
||||
view.assigned = collection.assigned;
|
||||
return view;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<CollectionView>) {
|
||||
return Object.assign(new CollectionView({ ...obj }), obj);
|
||||
}
|
||||
|
||||
encrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<Collection> {
|
||||
return Collection.fromCollectionView(this, encryptService, orgKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ 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 ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
|
||||
COLLECTION_DISK,
|
||||
"collections",
|
||||
{
|
||||
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export const DECRYPTED_COLLECTION_DATA_KEY = new UserKeyDefinition<CollectionView[] | null>(
|
||||
COLLECTION_MEMORY,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, firstValueFrom, from, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
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/key-management/crypto/models/enc-string";
|
||||
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { CollectionAdminService, CollectionService } from "../abstractions";
|
||||
@@ -28,37 +27,26 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
||||
private collectionService: CollectionService,
|
||||
) {}
|
||||
|
||||
async getAll(organizationId: string): Promise<CollectionAdminView[]> {
|
||||
const collectionResponse =
|
||||
await this.apiService.getManyCollectionsWithAccessDetails(organizationId);
|
||||
collectionAdminViews$(organizationId: string, userId: UserId): Observable<CollectionAdminView[]> {
|
||||
return combineLatest([
|
||||
this.keyService.orgKeys$(userId),
|
||||
from(this.apiService.getManyCollectionsWithAccessDetails(organizationId)),
|
||||
]).pipe(
|
||||
switchMap(([orgKeys, res]) => {
|
||||
if (res?.data == null || res.data.length === 0) {
|
||||
return of([]);
|
||||
}
|
||||
if (orgKeys == null) {
|
||||
throw new Error("No org keys found.");
|
||||
}
|
||||
|
||||
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.decryptMany(organizationId, collectionResponse.data);
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
collectionId: string,
|
||||
): Promise<CollectionAdminView | undefined> {
|
||||
const collectionResponse = await this.apiService.getCollectionAccessDetails(
|
||||
organizationId,
|
||||
collectionId,
|
||||
return this.decryptMany(organizationId, res.data, orgKeys);
|
||||
}),
|
||||
);
|
||||
|
||||
if (collectionResponse == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [view] = await this.decryptMany(organizationId, [collectionResponse]);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
async save(collection: CollectionAdminView, userId: UserId): Promise<CollectionDetailsResponse> {
|
||||
const request = await this.encrypt(collection);
|
||||
const request = await this.encrypt(collection, userId);
|
||||
|
||||
let response: CollectionDetailsResponse;
|
||||
if (collection.id == null) {
|
||||
@@ -112,52 +100,68 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
||||
private async decryptMany(
|
||||
organizationId: string,
|
||||
collections: CollectionResponse[] | CollectionAccessDetailsResponse[],
|
||||
orgKeys: Record<OrganizationId, OrgKey>,
|
||||
): Promise<CollectionAdminView[]> {
|
||||
const orgKey = await this.keyService.getOrgKey(organizationId);
|
||||
|
||||
const promises = collections.map(async (c) => {
|
||||
const view = new CollectionAdminView();
|
||||
view.id = c.id;
|
||||
view.name = await this.encryptService.decryptString(new EncString(c.name), orgKey);
|
||||
view.externalId = c.externalId;
|
||||
view.organizationId = c.organizationId;
|
||||
|
||||
if (isCollectionAccessDetailsResponse(c)) {
|
||||
view.groups = c.groups;
|
||||
view.users = c.users;
|
||||
view.assigned = c.assigned;
|
||||
view.readOnly = c.readOnly;
|
||||
view.hidePasswords = c.hidePasswords;
|
||||
view.manage = c.manage;
|
||||
view.unmanaged = c.unmanaged;
|
||||
return CollectionAdminView.fromCollectionAccessDetails(
|
||||
c,
|
||||
this.encryptService,
|
||||
orgKeys[organizationId as OrganizationId],
|
||||
);
|
||||
}
|
||||
|
||||
return view;
|
||||
return await CollectionAdminView.fromCollectionResponse(
|
||||
c,
|
||||
this.encryptService,
|
||||
orgKeys[organizationId as OrganizationId],
|
||||
);
|
||||
});
|
||||
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
|
||||
private async encrypt(model: CollectionAdminView): Promise<CollectionRequest> {
|
||||
if (model.organizationId == null) {
|
||||
private async encrypt(model: CollectionAdminView, userId: UserId): Promise<CollectionRequest> {
|
||||
if (!model.organizationId) {
|
||||
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 CollectionRequest();
|
||||
collection.externalId = model.externalId;
|
||||
collection.name = (await this.encryptService.encryptString(model.name, key)).encryptedString;
|
||||
collection.groups = model.groups.map(
|
||||
|
||||
const key = await firstValueFrom(
|
||||
this.keyService.orgKeys$(userId).pipe(
|
||||
map((orgKeys) => {
|
||||
if (!orgKeys) {
|
||||
throw new Error("No keys for the provided userId.");
|
||||
}
|
||||
|
||||
const key = orgKeys[model.organizationId];
|
||||
|
||||
if (key == null) {
|
||||
throw new Error("No key for this collection's organization.");
|
||||
}
|
||||
|
||||
return key;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const groups = model.groups.map(
|
||||
(group) =>
|
||||
new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords, group.manage),
|
||||
);
|
||||
collection.users = model.users.map(
|
||||
|
||||
const users = model.users.map(
|
||||
(user) =>
|
||||
new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords, user.manage),
|
||||
);
|
||||
return collection;
|
||||
|
||||
const collectionRequest = new CollectionRequest({
|
||||
name: await this.encryptService.encryptString(model.name, key),
|
||||
externalId: model.externalId,
|
||||
users,
|
||||
groups,
|
||||
});
|
||||
|
||||
return collectionRequest;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -390,9 +390,11 @@ const collectionDataFactory = (orgId?: OrganizationId) => {
|
||||
};
|
||||
|
||||
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;
|
||||
const id = Utils.newGuid() as CollectionId;
|
||||
const collectionView = new CollectionView({
|
||||
id,
|
||||
organizationId: orgId ?? (Utils.newGuid() as OrganizationId),
|
||||
name: "DEC_NAME_" + id,
|
||||
});
|
||||
return collectionView;
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ export class DefaultCollectionService implements CollectionService {
|
||||
/**
|
||||
* @returns a SingleUserState for encrypted collection data.
|
||||
*/
|
||||
private encryptedState(
|
||||
userId: UserId,
|
||||
): SingleUserState<Record<CollectionId, CollectionData | null>> {
|
||||
private encryptedState(userId: UserId): SingleUserState<Record<CollectionId, CollectionData>> {
|
||||
return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
|
||||
}
|
||||
|
||||
@@ -62,7 +60,7 @@ export class DefaultCollectionService implements CollectionService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.values(collections).map((c) => new Collection(c));
|
||||
return Object.values(collections).map((c) => Collection.fromCollectionData(c));
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -110,8 +108,8 @@ export class DefaultCollectionService implements CollectionService {
|
||||
if (collections == null) {
|
||||
collections = {};
|
||||
}
|
||||
collections[toUpdate.id] = toUpdate;
|
||||
|
||||
collections[toUpdate.id] = toUpdate;
|
||||
return collections;
|
||||
});
|
||||
|
||||
@@ -121,7 +119,7 @@ export class DefaultCollectionService implements CollectionService {
|
||||
if (!orgKeys) {
|
||||
throw new Error("No key for this collection's organization.");
|
||||
}
|
||||
return this.decryptMany$([new Collection(toUpdate)], orgKeys);
|
||||
return this.decryptMany$([Collection.fromCollectionData(toUpdate)], orgKeys);
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -177,10 +175,6 @@ export class DefaultCollectionService implements CollectionService {
|
||||
}
|
||||
|
||||
async encrypt(model: CollectionView, userId: UserId): Promise<Collection> {
|
||||
if (model.organizationId == null) {
|
||||
throw new Error("Collection has no organization id.");
|
||||
}
|
||||
|
||||
const key = await firstValueFrom(
|
||||
this.keyService.orgKeys$(userId).pipe(
|
||||
filter((orgKeys) => !!orgKeys),
|
||||
@@ -188,13 +182,7 @@ export class DefaultCollectionService implements CollectionService {
|
||||
),
|
||||
);
|
||||
|
||||
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;
|
||||
return await model.encrypt(key, this.encryptService);
|
||||
}
|
||||
|
||||
// TODO: this should be private.
|
||||
@@ -211,7 +199,12 @@ export class DefaultCollectionService implements CollectionService {
|
||||
|
||||
collections.forEach((collection) => {
|
||||
decCollections.push(
|
||||
from(collection.decrypt(orgKeys[collection.organizationId as OrganizationId])),
|
||||
from(
|
||||
collection.decrypt(
|
||||
orgKeys[collection.organizationId as OrganizationId],
|
||||
this.encryptService,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -223,9 +216,8 @@ export class DefaultCollectionService implements CollectionService {
|
||||
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 collectionCopy = Object.assign(new CollectionView({ ...c }), c);
|
||||
|
||||
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EncString as SdkEncString } from "@bitwarden/sdk-internal";
|
||||
|
||||
type OrganizationUserBulkRequestEntry = {
|
||||
id: string;
|
||||
key: string;
|
||||
@@ -5,8 +7,10 @@ type OrganizationUserBulkRequestEntry = {
|
||||
|
||||
export class OrganizationUserBulkConfirmRequest {
|
||||
keys: OrganizationUserBulkRequestEntry[];
|
||||
defaultUserCollectionName: SdkEncString | undefined;
|
||||
|
||||
constructor(keys: OrganizationUserBulkRequestEntry[]) {
|
||||
constructor(keys: OrganizationUserBulkRequestEntry[], defaultUserCollectionName?: SdkEncString) {
|
||||
this.keys = keys;
|
||||
this.defaultUserCollectionName = defaultUserCollectionName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<ng-container
|
||||
*ngIf="{
|
||||
selectedRegion: selectedRegion$ | async,
|
||||
} as data"
|
||||
>
|
||||
<div class="tw-text-sm tw-text-muted tw-leading-7 tw-font-normal tw-pl-4">
|
||||
{{ "accessing" | i18n }}:
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggle(null)"
|
||||
cdkOverlayOrigin
|
||||
#trigger="cdkOverlayOrigin"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="cdk-overlay-container"
|
||||
>
|
||||
<span class="tw-text-primary-600 tw-text-sm tw-font-semibold">
|
||||
<ng-container *ngIf="data.selectedRegion; else fallback">
|
||||
{{ data.selectedRegion.domain }}
|
||||
</ng-container>
|
||||
<ng-template #fallback>
|
||||
{{ "selfHostedServer" | i18n }}
|
||||
</ng-template>
|
||||
</span>
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="trigger"
|
||||
[cdkConnectedOverlayOpen]="isOpen"
|
||||
[cdkConnectedOverlayPositions]="overlayPosition"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
|
||||
(backdropClick)="isOpen = false"
|
||||
(detach)="close()"
|
||||
>
|
||||
<div class="tw-box-content">
|
||||
<div
|
||||
class="tw-bg-background tw-w-full tw-shadow-md tw-p-2 tw-rounded-md"
|
||||
data-testid="environment-selector-dialog"
|
||||
[@transformPanel]="'open'"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<ng-container *ngFor="let region of availableRegions; let i = index">
|
||||
<button
|
||||
type="button"
|
||||
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-pr-2 tw-rounded-md"
|
||||
(click)="toggle(region.key)"
|
||||
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
|
||||
[attr.data-testid]="'environment-selector-dialog-item-' + i"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="data.selectedRegion === region ? 'visible' : 'hidden'"
|
||||
></i>
|
||||
<span>{{ region.domain }}</span>
|
||||
</button>
|
||||
<br />
|
||||
</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-pr-2 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-rounded-md"
|
||||
(click)="toggle(ServerEnvironmentType.SelfHosted)"
|
||||
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
|
||||
data-testid="environment-selector-dialog-item-self-hosted"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="data.selectedRegion ? 'hidden' : 'visible'"
|
||||
></i>
|
||||
<span>{{ "selfHostedServer" | i18n }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
@@ -1,135 +0,0 @@
|
||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
||||
import { ConnectedPosition } from "@angular/cdk/overlay";
|
||||
import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Observable, map, 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
RegionConfig,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
export const ExtensionDefaultOverlayPosition: ConnectedPosition[] = [
|
||||
{
|
||||
originX: "start",
|
||||
originY: "top",
|
||||
overlayX: "start",
|
||||
overlayY: "bottom",
|
||||
},
|
||||
];
|
||||
export const DesktopDefaultOverlayPosition: ConnectedPosition[] = [
|
||||
{
|
||||
originX: "start",
|
||||
originY: "top",
|
||||
overlayX: "start",
|
||||
overlayY: "bottom",
|
||||
},
|
||||
];
|
||||
|
||||
export interface EnvironmentSelectorRouteData {
|
||||
overlayPosition?: ConnectedPosition[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "environment-selector",
|
||||
templateUrl: "environment-selector.component.html",
|
||||
animations: [
|
||||
trigger("transformPanel", [
|
||||
state(
|
||||
"void",
|
||||
style({
|
||||
opacity: 0,
|
||||
}),
|
||||
),
|
||||
transition(
|
||||
"void => open",
|
||||
animate(
|
||||
"100ms linear",
|
||||
style({
|
||||
opacity: 1,
|
||||
}),
|
||||
),
|
||||
),
|
||||
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
|
||||
]),
|
||||
],
|
||||
standalone: false,
|
||||
})
|
||||
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
|
||||
@Output() onOpenSelfHostedSettings = new EventEmitter<void>();
|
||||
@Input() overlayPosition: ConnectedPosition[] = [
|
||||
{
|
||||
originX: "start",
|
||||
originY: "bottom",
|
||||
overlayX: "start",
|
||||
overlayY: "top",
|
||||
},
|
||||
];
|
||||
|
||||
protected isOpen = false;
|
||||
protected ServerEnvironmentType = Region;
|
||||
protected availableRegions = this.environmentService.availableRegions();
|
||||
protected selectedRegion$: Observable<RegionConfig | undefined> =
|
||||
this.environmentService.environment$.pipe(
|
||||
map((e) => e.getRegion()),
|
||||
map((r) => this.availableRegions.find((ar) => ar.key === r)),
|
||||
);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
protected environmentService: EnvironmentService,
|
||||
private route: ActivatedRoute,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
|
||||
if (data && data["overlayPosition"]) {
|
||||
this.overlayPosition = data["overlayPosition"];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async toggle(option: Region) {
|
||||
this.isOpen = !this.isOpen;
|
||||
if (option === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the self-hosted settings dialog when the self-hosted option is selected.
|
||||
*/
|
||||
if (option === Region.SelfHosted) {
|
||||
const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
|
||||
if (dialogResult) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("environmentSaved"),
|
||||
});
|
||||
}
|
||||
// Don't proceed to setEnvironment when the self-hosted dialog is cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
await this.environmentService.setEnvironment(option);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@
|
||||
bit-item-content
|
||||
type="button"
|
||||
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
|
||||
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
|
||||
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
|
||||
(click)="answerAuthRequest(device.pendingAuthRequest)"
|
||||
>
|
||||
<!-- Default Content -->
|
||||
<span class="tw-text-base">{{ device.displayName }}</span>
|
||||
@@ -21,7 +20,7 @@
|
||||
|
||||
<!-- Secondary Content -->
|
||||
<span slot="secondary" class="tw-text-sm">
|
||||
<span>{{ "needsApproval" | i18n }}</span>
|
||||
<br />
|
||||
<div>
|
||||
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
|
||||
import { BadgeModule, ItemModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
|
||||
/** Displays user devices in an item list view */
|
||||
@Component({
|
||||
@@ -20,24 +16,12 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
})
|
||||
export class DeviceManagementItemGroupComponent {
|
||||
@Input() devices: DeviceDisplayData[] = [];
|
||||
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
|
||||
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
protected answerAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
if (pendingAuthRequest == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginApprovalDialog = LoginApprovalDialogComponent.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);
|
||||
}
|
||||
this.onAuthRequestAnswered.emit(pendingAuthRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
|
||||
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="64">
|
||||
<!-- Table Header -->
|
||||
<ng-container header>
|
||||
<th
|
||||
@@ -6,7 +6,6 @@
|
||||
[class]="column.headerClass"
|
||||
bitCell
|
||||
[bitSortable]="column.sortable ? column.name : ''"
|
||||
[default]="column.name === 'loginStatus' ? 'desc' : false"
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
@@ -17,24 +16,17 @@
|
||||
<!-- Table Rows -->
|
||||
<ng-template bitRowDef let-device>
|
||||
<!-- Column: Device Name -->
|
||||
<td bitCell class="tw-flex tw-gap-2">
|
||||
<td bitCell class="tw-flex tw-gap-2 tw-items-center tw-h-16">
|
||||
<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)"
|
||||
>
|
||||
<a bitLink href="#" appStopClick (click)="answerAuthRequest(device.pendingAuthRequest)">
|
||||
{{ device.displayName }}
|
||||
</a>
|
||||
<div class="tw-text-sm tw-text-muted">
|
||||
{{ "needsApproval" | i18n }}
|
||||
</div>
|
||||
<br />
|
||||
} @else {
|
||||
<span>{{ device.displayName }}</span>
|
||||
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
@@ -8,16 +7,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
LinkModule,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
|
||||
/** Displays user devices in a sortable table view */
|
||||
@Component({
|
||||
@@ -28,6 +23,8 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
})
|
||||
export class DeviceManagementTableComponent implements OnChanges {
|
||||
@Input() devices: DeviceDisplayData[] = [];
|
||||
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
|
||||
|
||||
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
|
||||
|
||||
protected readonly columnConfig = [
|
||||
@@ -51,10 +48,7 @@ export class DeviceManagementTableComponent implements OnChanges {
|
||||
},
|
||||
];
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.devices) {
|
||||
@@ -62,24 +56,10 @@ export class DeviceManagementTableComponent implements OnChanges {
|
||||
}
|
||||
}
|
||||
|
||||
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
protected answerAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
if (pendingAuthRequest == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginApprovalDialog = LoginApprovalDialogComponent.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,
|
||||
);
|
||||
}
|
||||
this.onAuthRequestAnswered.emit(pendingAuthRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,13 @@
|
||||
<auth-device-management-table
|
||||
ngClass="tw-hidden md:tw-block"
|
||||
[devices]="devices"
|
||||
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
|
||||
></auth-device-management-table>
|
||||
|
||||
<!-- List View: displays on small screens -->
|
||||
<auth-device-management-item-group
|
||||
ngClass="md:tw-hidden"
|
||||
[devices]="devices"
|
||||
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
|
||||
></auth-device-management-item-group>
|
||||
}
|
||||
|
||||
@@ -16,14 +16,18 @@ 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 { ButtonModule, DialogService, PopoverModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { LoginApprovalDialogComponent } from "../login-approval";
|
||||
|
||||
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
|
||||
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
|
||||
import { DeviceManagementTableComponent } from "./device-management-table.component";
|
||||
import { clearAuthRequestAndResortDevices, resortDevices } from "./resort-devices.helper";
|
||||
|
||||
export interface DeviceDisplayData {
|
||||
creationDate: string;
|
||||
displayName: string;
|
||||
firstLogin: Date;
|
||||
icon: string;
|
||||
@@ -66,6 +70,7 @@ export class DeviceManagementComponent implements OnInit {
|
||||
private destroyRef: DestroyRef,
|
||||
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
|
||||
private devicesService: DevicesServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private messageListener: MessageListener,
|
||||
private validationService: ValidationService,
|
||||
@@ -130,6 +135,7 @@ export class DeviceManagementComponent implements OnInit {
|
||||
}
|
||||
|
||||
return {
|
||||
creationDate: device.creationDate,
|
||||
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
|
||||
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
|
||||
icon: this.getDeviceIcon(device.type),
|
||||
@@ -141,7 +147,8 @@ export class DeviceManagementComponent implements OnInit {
|
||||
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
|
||||
};
|
||||
})
|
||||
.filter((device) => device !== null);
|
||||
.filter((device) => device !== null)
|
||||
.sort(resortDevices);
|
||||
}
|
||||
|
||||
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
|
||||
@@ -151,6 +158,7 @@ export class DeviceManagementComponent implements OnInit {
|
||||
}
|
||||
|
||||
const upsertDevice: DeviceDisplayData = {
|
||||
creationDate: "",
|
||||
displayName: this.devicesService.getReadableDeviceTypeName(
|
||||
authRequestResponse.requestDeviceTypeValue,
|
||||
),
|
||||
@@ -174,8 +182,9 @@ export class DeviceManagementComponent implements OnInit {
|
||||
);
|
||||
|
||||
if (existingDevice?.id && existingDevice.creationDate) {
|
||||
upsertDevice.id = existingDevice.id;
|
||||
upsertDevice.creationDate = existingDevice.creationDate;
|
||||
upsertDevice.firstLogin = new Date(existingDevice.creationDate);
|
||||
upsertDevice.id = existingDevice.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +195,10 @@ export class DeviceManagementComponent implements OnInit {
|
||||
if (existingDeviceIndex >= 0) {
|
||||
// Update existing device in device list
|
||||
this.devices[existingDeviceIndex] = upsertDevice;
|
||||
this.devices = [...this.devices];
|
||||
this.devices = [...this.devices].sort(resortDevices);
|
||||
} else {
|
||||
// Add new device to device list
|
||||
this.devices = [upsertDevice, ...this.devices];
|
||||
this.devices = [upsertDevice, ...this.devices].sort(resortDevices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,4 +236,18 @@ export class DeviceManagementComponent implements OnInit {
|
||||
const metadata = DeviceTypeMetadata[type];
|
||||
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
|
||||
}
|
||||
|
||||
protected async handleAuthRequestAnswered(pendingAuthRequest: DevicePendingAuthRequest) {
|
||||
const loginApprovalDialog = LoginApprovalDialogComponent.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function clearAuthRequestAndResortDevices(
|
||||
*
|
||||
* This is a helper function that gets passed to the `Array.sort()` method
|
||||
*/
|
||||
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
|
||||
export function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
|
||||
// Devices with a pending auth request should be first
|
||||
if (deviceA.pendingAuthRequest) {
|
||||
return -1;
|
||||
@@ -40,11 +40,11 @@ function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort the rest by display name (alphabetically)
|
||||
if (deviceA.displayName < deviceB.displayName) {
|
||||
// Then sort the rest by creation date (newest to oldest)
|
||||
if (deviceA.creationDate > deviceB.creationDate) {
|
||||
return -1;
|
||||
}
|
||||
if (deviceA.displayName > deviceB.displayName) {
|
||||
if (deviceA.creationDate < deviceB.creationDate) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<ng-container
|
||||
*ngIf="{
|
||||
selectedRegion: selectedRegion$ | async,
|
||||
} as data"
|
||||
>
|
||||
<div class="tw-mb-1">
|
||||
<bit-menu #environmentOptions>
|
||||
<button
|
||||
*ngFor="let region of availableRegions; let i = index"
|
||||
bitMenuItem
|
||||
type="button"
|
||||
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
|
||||
(click)="toggle(region.key)"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="data.selectedRegion === region ? 'visible' : 'hidden'"
|
||||
></i>
|
||||
<span>{{ region.domain }}</span>
|
||||
</button>
|
||||
<button
|
||||
bitMenuItem
|
||||
type="button"
|
||||
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
|
||||
(click)="toggle(ServerEnvironmentType.SelfHosted)"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="data.selectedRegion ? 'hidden' : 'visible'"
|
||||
></i>
|
||||
<span>{{ "selfHostedServer" | i18n }}</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
<div bitTypography="body2">
|
||||
{{ "accessing" | i18n }}:
|
||||
<button [bitMenuTriggerFor]="environmentOptions" bitLink type="button">
|
||||
<b class="tw-text-primary-600 tw-font-semibold">{{
|
||||
data.selectedRegion?.domain || ("selfHostedServer" | i18n)
|
||||
}}</b>
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy } from "@angular/core";
|
||||
import { Observable, map, Subject } 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
RegionConfig,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
DialogService,
|
||||
LinkModule,
|
||||
MenuModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@Component({
|
||||
selector: "environment-selector",
|
||||
templateUrl: "environment-selector.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, I18nPipe, MenuModule, LinkModule, TypographyModule],
|
||||
})
|
||||
export class EnvironmentSelectorComponent implements OnDestroy {
|
||||
protected ServerEnvironmentType = Region;
|
||||
protected availableRegions = this.environmentService.availableRegions();
|
||||
protected selectedRegion$: Observable<RegionConfig | undefined> =
|
||||
this.environmentService.environment$.pipe(
|
||||
map((e) => e.getRegion()),
|
||||
map((r) => this.availableRegions.find((ar) => ar.key === r)),
|
||||
);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
public environmentService: EnvironmentService,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async toggle(option: Region) {
|
||||
if (option === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the self-hosted settings dialog when the self-hosted option is selected.
|
||||
*/
|
||||
if (option === Region.SelfHosted) {
|
||||
const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
|
||||
if (dialogResult) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("environmentSaved"),
|
||||
});
|
||||
}
|
||||
// Don't proceed to setEnvironment when the self-hosted dialog is cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
await this.environmentService.setEnvironment(option);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { SimpleChange } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SelectModule, FormFieldModule, BitSubmitDirective } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { ManageTaxInformationComponent } from "./manage-tax-information.component";
|
||||
|
||||
describe("ManageTaxInformationComponent", () => {
|
||||
let sut: ManageTaxInformationComponent;
|
||||
let fixture: ComponentFixture<ManageTaxInformationComponent>;
|
||||
let mockTaxService: MockProxy<TaxServiceAbstraction>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTaxService = mock();
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ManageTaxInformationComponent],
|
||||
providers: [
|
||||
{ provide: TaxServiceAbstraction, useValue: mockTaxService },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
SelectModule,
|
||||
FormFieldModule,
|
||||
BitSubmitDirective,
|
||||
I18nPipe,
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ManageTaxInformationComponent);
|
||||
sut = fixture.componentInstance;
|
||||
fixture.autoDetectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates successfully", () => {
|
||||
expect(sut).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with all values empty in startWith", async () => {
|
||||
// Arrange
|
||||
sut.startWith = {
|
||||
country: "",
|
||||
postalCode: "",
|
||||
taxId: "",
|
||||
line1: "",
|
||||
line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
};
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const startWithValue = sut.startWith;
|
||||
expect(startWithValue.line1).toHaveLength(0);
|
||||
expect(startWithValue.line2).toHaveLength(0);
|
||||
expect(startWithValue.city).toHaveLength(0);
|
||||
expect(startWithValue.state).toHaveLength(0);
|
||||
expect(startWithValue.postalCode).toHaveLength(0);
|
||||
expect(startWithValue.country).toHaveLength(0);
|
||||
expect(startWithValue.taxId).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should update the tax information protected state when form is updated", async () => {
|
||||
// Arrange
|
||||
const line1Value = "123 Street";
|
||||
const line2Value = "Apt. 5";
|
||||
const cityValue = "New York";
|
||||
const stateValue = "NY";
|
||||
const countryValue = "USA";
|
||||
const postalCodeValue = "123 Street";
|
||||
|
||||
sut.startWith = {
|
||||
country: countryValue,
|
||||
postalCode: "",
|
||||
taxId: "",
|
||||
line1: "",
|
||||
line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
};
|
||||
sut.showTaxIdField = false;
|
||||
mockTaxService.isCountrySupported.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await sut.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='line1']",
|
||||
);
|
||||
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='line2']",
|
||||
);
|
||||
const city: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='city']",
|
||||
);
|
||||
const state: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='state']",
|
||||
);
|
||||
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='postalCode']",
|
||||
);
|
||||
|
||||
line1.value = line1Value;
|
||||
line2.value = line2Value;
|
||||
city.value = cityValue;
|
||||
state.value = stateValue;
|
||||
postalCode.value = postalCodeValue;
|
||||
|
||||
line1.dispatchEvent(new Event("input"));
|
||||
line2.dispatchEvent(new Event("input"));
|
||||
city.dispatchEvent(new Event("input"));
|
||||
state.dispatchEvent(new Event("input"));
|
||||
postalCode.dispatchEvent(new Event("input"));
|
||||
await fixture.whenStable();
|
||||
|
||||
// Assert
|
||||
|
||||
// Assert that the internal tax information reflects the form
|
||||
const taxInformation = sut.getTaxInformation();
|
||||
expect(taxInformation.line1).toBe(line1Value);
|
||||
expect(taxInformation.line2).toBe(line2Value);
|
||||
expect(taxInformation.city).toBe(cityValue);
|
||||
expect(taxInformation.state).toBe(stateValue);
|
||||
expect(taxInformation.postalCode).toBe(postalCodeValue);
|
||||
expect(taxInformation.country).toBe(countryValue);
|
||||
expect(taxInformation.taxId).toHaveLength(0);
|
||||
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should not show address fields except postal code if country is not supported for taxes", async () => {
|
||||
// Arrange
|
||||
const countryValue = "UNKNOWN";
|
||||
sut.startWith = {
|
||||
country: countryValue,
|
||||
postalCode: "",
|
||||
taxId: "",
|
||||
line1: "",
|
||||
line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
};
|
||||
sut.showTaxIdField = false;
|
||||
mockTaxService.isCountrySupported.mockResolvedValue(false);
|
||||
|
||||
// Act
|
||||
await sut.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='line1']",
|
||||
);
|
||||
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='line2']",
|
||||
);
|
||||
const city: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='city']",
|
||||
);
|
||||
const state: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='state']",
|
||||
);
|
||||
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='postalCode']",
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(line1).toBeNull();
|
||||
expect(line2).toBeNull();
|
||||
expect(city).toBeNull();
|
||||
expect(state).toBeNull();
|
||||
//Should be visible
|
||||
expect(postalCode).toBeTruthy();
|
||||
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not show the tax id field if showTaxIdField is set to false", async () => {
|
||||
// Arrange
|
||||
const countryValue = "USA";
|
||||
sut.startWith = {
|
||||
country: countryValue,
|
||||
postalCode: "",
|
||||
taxId: "",
|
||||
line1: "",
|
||||
line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
};
|
||||
|
||||
sut.showTaxIdField = false;
|
||||
mockTaxService.isCountrySupported.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await sut.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const taxId: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='taxId']",
|
||||
);
|
||||
expect(taxId).toBeNull();
|
||||
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should clear the tax id field if showTaxIdField is set to false after being true", async () => {
|
||||
// Arrange
|
||||
const countryValue = "USA";
|
||||
const taxIdValue = "A12345678";
|
||||
|
||||
sut.startWith = {
|
||||
country: countryValue,
|
||||
postalCode: "",
|
||||
taxId: taxIdValue,
|
||||
line1: "",
|
||||
line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
};
|
||||
sut.showTaxIdField = true;
|
||||
|
||||
mockTaxService.isCountrySupported.mockResolvedValue(true);
|
||||
await sut.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
const initialTaxIdValue = fixture.nativeElement.querySelector(
|
||||
"input[formControlName='taxId']",
|
||||
).value;
|
||||
|
||||
// Act
|
||||
sut.showTaxIdField = false;
|
||||
sut.ngOnChanges({ showTaxIdField: new SimpleChange(true, false, false) });
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const taxId = fixture.nativeElement.querySelector("input[formControlName='taxId']");
|
||||
expect(taxId).toBeNull();
|
||||
|
||||
const taxInformation = sut.getTaxInformation();
|
||||
expect(taxInformation.taxId).toBeNull();
|
||||
expect(initialTaxIdValue).toEqual(taxIdValue);
|
||||
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
|
||||
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
} from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { debounceTime } from "rxjs/operators";
|
||||
@@ -13,7 +22,7 @@ import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/model
|
||||
templateUrl: "./manage-tax-information.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class ManageTaxInformationComponent implements OnInit, OnDestroy {
|
||||
export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() startWith: TaxInformation;
|
||||
@Input() onSubmit?: (taxInformation: TaxInformation) => Promise<void>;
|
||||
@Input() showTaxIdField: boolean = true;
|
||||
@@ -56,7 +65,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
this.markAllAsTouched();
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +74,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
validate(): boolean {
|
||||
this.formGroup.markAllAsTouched();
|
||||
this.markAllAsTouched();
|
||||
return this.formGroup.valid;
|
||||
}
|
||||
|
||||
@@ -142,6 +151,14 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
// Clear the value of the tax-id if states have been changed in the parent component
|
||||
const showTaxIdField = changes["showTaxIdField"];
|
||||
if (showTaxIdField && !showTaxIdField.currentValue) {
|
||||
this.formGroup.controls.taxId.setValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
@@ -149,6 +149,10 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service
|
||||
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { TaxService } from "@bitwarden/common/billing/services/tax.service";
|
||||
import {
|
||||
DefaultKeyGenerationService,
|
||||
KeyGenerationService,
|
||||
} from "@bitwarden/common/key-management/crypto";
|
||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
|
||||
@@ -184,7 +188,6 @@ import {
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service";
|
||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -222,7 +225,6 @@ import { DefaultBroadcasterService } from "@bitwarden/common/platform/services/d
|
||||
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
|
||||
import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service";
|
||||
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
|
||||
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
|
||||
@@ -537,6 +539,7 @@ const safeProviders: SafeProvider[] = [
|
||||
accountService: AccountServiceAbstraction,
|
||||
logService: LogService,
|
||||
cipherEncryptionService: CipherEncryptionService,
|
||||
messagingService: MessagingServiceAbstraction,
|
||||
) =>
|
||||
new CipherService(
|
||||
keyService,
|
||||
@@ -553,6 +556,7 @@ const safeProviders: SafeProvider[] = [
|
||||
accountService,
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messagingService,
|
||||
),
|
||||
deps: [
|
||||
KeyService,
|
||||
@@ -569,6 +573,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AccountServiceAbstraction,
|
||||
LogService,
|
||||
CipherEncryptionService,
|
||||
MessagingServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -657,15 +662,15 @@ const safeProviders: SafeProvider[] = [
|
||||
GlobalStateProvider,
|
||||
SUPPORTS_SECURE_STORAGE,
|
||||
SECURE_STORAGE,
|
||||
KeyGenerationServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
EncryptService,
|
||||
LogService,
|
||||
LOGOUT_CALLBACK,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyGenerationServiceAbstraction,
|
||||
useClass: KeyGenerationService,
|
||||
provide: KeyGenerationService,
|
||||
useClass: DefaultKeyGenerationService,
|
||||
deps: [CryptoFunctionServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -674,7 +679,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
PinServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
KeyGenerationServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
EncryptService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
@@ -764,7 +769,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
KeyService,
|
||||
I18nServiceAbstraction,
|
||||
KeyGenerationServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
SendStateProviderAbstraction,
|
||||
EncryptService,
|
||||
],
|
||||
@@ -1010,7 +1015,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
StateProvider,
|
||||
StateServiceAbstraction,
|
||||
KeyGenerationServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
EncryptService,
|
||||
LogService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
@@ -1032,7 +1037,7 @@ const safeProviders: SafeProvider[] = [
|
||||
TokenServiceAbstraction,
|
||||
LogService,
|
||||
OrganizationServiceAbstraction,
|
||||
KeyGenerationServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
LOGOUT_CALLBACK,
|
||||
StateProvider,
|
||||
],
|
||||
@@ -1191,7 +1196,7 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: DeviceTrustServiceAbstraction,
|
||||
useClass: DeviceTrustService,
|
||||
deps: [
|
||||
KeyGenerationServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
@@ -1227,7 +1232,7 @@ const safeProviders: SafeProvider[] = [
|
||||
CryptoFunctionServiceAbstraction,
|
||||
EncryptService,
|
||||
KdfConfigService,
|
||||
KeyGenerationServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
LogService,
|
||||
StateProvider,
|
||||
],
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
<div class="tw-flex tw-justify-center tw-items-center" aria-hidden="true">
|
||||
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
|
||||
<div
|
||||
class="tw-flex tw-justify-center tw-items-center"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ng-container *ngIf="data$ | async as data">
|
||||
<img
|
||||
[src]="data.image"
|
||||
*ngIf="data.imageEnabled && data.image"
|
||||
class="tw-size-6 tw-rounded-md"
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
[ngClass]="{ 'tw-invisible tw-absolute': !imageLoaded() }"
|
||||
(load)="imageLoaded.set(true)"
|
||||
(error)="imageLoaded.set(false)"
|
||||
/>
|
||||
<i
|
||||
class="tw-w-6 tw-text-muted bwi bwi-lg {{ data.icon }}"
|
||||
*ngIf="!data.imageEnabled || !data.image || !imageLoaded()"
|
||||
></i>
|
||||
@if (data.imageEnabled && data.image) {
|
||||
<img
|
||||
[src]="data.image"
|
||||
class="tw-rounded-md"
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
[ngClass]="{
|
||||
'tw-invisible tw-absolute': !imageLoaded(),
|
||||
'tw-size-6': !coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
(load)="imageLoaded.set(true)"
|
||||
(error)="imageLoaded.set(false)"
|
||||
/>
|
||||
}
|
||||
@if (!data.imageEnabled || !data.image || !imageLoaded()) {
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-flex tw-items-center tw-justify-center': coloredIcon(),
|
||||
'tw-bg-illustration-bg-primary tw-rounded-full':
|
||||
data.icon?.startsWith('bwi-') && coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
>
|
||||
<i
|
||||
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
|
||||
[ngStyle]="{
|
||||
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
|
||||
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
|
||||
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,11 @@ export class IconComponent {
|
||||
*/
|
||||
cipher = input.required<CipherViewLike>();
|
||||
|
||||
/**
|
||||
* coloredIcon will adjust the size of favicons and the colors of the text icon when user is in the item details view.
|
||||
*/
|
||||
coloredIcon = input<boolean>(false);
|
||||
|
||||
imageLoaded = signal(false);
|
||||
|
||||
protected data$: Observable<CipherIconDetails>;
|
||||
|
||||
@@ -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 tw-mb-4"
|
||||
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-2 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>
|
||||
@@ -20,6 +20,7 @@
|
||||
(click)="handleDismiss()"
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
class="-tw-me-2"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { combineLatest, Observable, of, switchMap } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
@@ -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! as OrganizationId),
|
||||
);
|
||||
|
||||
// When the user has dismissed the nudge or spotlight, return the nudge status directly
|
||||
|
||||
@@ -5,7 +5,7 @@ import { combineLatest, Observable, of, switchMap } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
@@ -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! as OrganizationId),
|
||||
);
|
||||
|
||||
// When the user has dismissed the nudge or spotlight, return the nudge status directly
|
||||
|
||||
@@ -8,7 +8,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -31,10 +30,6 @@ describe("Vault Nudges Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
let testBed: TestBed;
|
||||
const mockConfigService = {
|
||||
getFeatureFlag$: jest.fn().mockReturnValue(of(true)),
|
||||
getFeatureFlag: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const nudgeServices = [
|
||||
EmptyVaultNudgeService,
|
||||
@@ -58,7 +53,6 @@ describe("Vault Nudges Service", () => {
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{
|
||||
provide: HasItemsNudgeService,
|
||||
useValue: mock<HasItemsNudgeService>(),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs";
|
||||
import { combineLatest, map, Observable, shareReplay } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { UserKeyDefinition, NUDGES_DISK } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
@@ -83,7 +81,6 @@ export class NudgesService {
|
||||
* @private
|
||||
*/
|
||||
private defaultNudgeService = inject(DefaultSingleNudgeService);
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
private getNudgeService(nudge: NudgeType): SingleNudgeService {
|
||||
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
|
||||
@@ -95,16 +92,9 @@ export class NudgesService {
|
||||
* @param userId
|
||||
*/
|
||||
showNudgeSpotlight$(nudge: NudgeType, userId: UserId): Observable<boolean> {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of(false);
|
||||
}
|
||||
return this.getNudgeService(nudge)
|
||||
.nudgeStatus$(nudge, userId)
|
||||
.pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed));
|
||||
}),
|
||||
);
|
||||
return this.getNudgeService(nudge)
|
||||
.nudgeStatus$(nudge, userId)
|
||||
.pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,16 +103,9 @@ export class NudgesService {
|
||||
* @param userId
|
||||
*/
|
||||
showNudgeBadge$(nudge: NudgeType, userId: UserId): Observable<boolean> {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of(false);
|
||||
}
|
||||
return this.getNudgeService(nudge)
|
||||
.nudgeStatus$(nudge, userId)
|
||||
.pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed));
|
||||
}),
|
||||
);
|
||||
return this.getNudgeService(nudge)
|
||||
.nudgeStatus$(nudge, userId)
|
||||
.pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,14 +114,7 @@ export class NudgesService {
|
||||
* @param userId
|
||||
*/
|
||||
showNudgeStatus$(nudge: NudgeType, userId: UserId) {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus);
|
||||
}
|
||||
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
|
||||
}),
|
||||
);
|
||||
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
<bit-label>
|
||||
{{ "rotateAccountEncKey" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
InputModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
Translation,
|
||||
} from "@bitwarden/components";
|
||||
@@ -112,6 +113,7 @@ interface InputPasswordForm {
|
||||
PasswordCalloutComponent,
|
||||
PasswordStrengthV2Component,
|
||||
ReactiveFormsModule,
|
||||
LinkModule,
|
||||
SharedModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ export abstract class LoginComponentService {
|
||||
* Gets the organization policies if there is an organization invite.
|
||||
* - Used by: Web
|
||||
*/
|
||||
getOrgPoliciesFromOrgInvite?: () => Promise<PasswordPolicies | null>;
|
||||
getOrgPoliciesFromOrgInvite?: (email: string) => Promise<PasswordPolicies | null>;
|
||||
|
||||
/**
|
||||
* Indicates whether login with passkey is supported on the given client
|
||||
|
||||
@@ -80,6 +80,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
clientType: ClientType;
|
||||
ClientType = ClientType;
|
||||
orgPoliciesFromInvite: PasswordPolicies | null = null;
|
||||
LoginUiState = LoginUiState;
|
||||
isKnownDevice = false;
|
||||
loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY;
|
||||
@@ -232,11 +233,12 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
// 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()
|
||||
this.orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite
|
||||
? await this.loginComponentService.getOrgPoliciesFromOrgInvite(email)
|
||||
: null;
|
||||
|
||||
const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
|
||||
const orgMasterPasswordPolicyOptions =
|
||||
this.orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
|
||||
|
||||
const credentials = new PasswordLoginCredentials(
|
||||
email,
|
||||
@@ -327,25 +329,18 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
// TODO: PM-18269 - evaluate if we can combine this with the
|
||||
// password evaluation done in the password login strategy.
|
||||
// If there's an existing org invite, use it to get the org's password policies
|
||||
// so we can evaluate the MP against the org policies
|
||||
if (this.loginComponentService.getOrgPoliciesFromOrgInvite) {
|
||||
const orgPolicies: PasswordPolicies | null =
|
||||
await this.loginComponentService.getOrgPoliciesFromOrgInvite();
|
||||
if (this.orgPoliciesFromInvite) {
|
||||
// Since we have retrieved the policies, we can go ahead and set them into state for future use
|
||||
// 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, this.orgPoliciesFromInvite.policies);
|
||||
|
||||
if (orgPolicies) {
|
||||
// Since we have retrieved the policies, we can go ahead and set them into state for future use
|
||||
// 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);
|
||||
|
||||
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
|
||||
orgPolicies.enforcedPasswordPolicyOptions,
|
||||
);
|
||||
if (isPasswordChangeRequired) {
|
||||
await this.router.navigate(["change-password"]);
|
||||
return;
|
||||
}
|
||||
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
|
||||
this.orgPoliciesFromInvite.enforcedPasswordPolicyOptions,
|
||||
);
|
||||
if (isPasswordChangeRequired) {
|
||||
await this.router.navigate(["change-password"]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ProductTierType } from "../../../billing/enums";
|
||||
import { OrganizationId } from "../../../types/guid";
|
||||
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
|
||||
import { PermissionsApi } from "../api/permissions.api";
|
||||
import { OrganizationData } from "../data/organization.data";
|
||||
|
||||
export class Organization {
|
||||
id: string;
|
||||
id: OrganizationId;
|
||||
name: string;
|
||||
status: OrganizationUserStatusType;
|
||||
|
||||
@@ -99,7 +100,7 @@ export class Organization {
|
||||
return;
|
||||
}
|
||||
|
||||
this.id = obj.id;
|
||||
this.id = obj.id as OrganizationId;
|
||||
this.name = obj.name;
|
||||
this.status = obj.status;
|
||||
this.type = obj.type;
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import Domain from "../../../platform/models/domain/domain-base";
|
||||
import { PolicyId } from "../../../types/guid";
|
||||
import { OrganizationId, PolicyId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../data/policy.data";
|
||||
import { PolicyResponse } from "../response/policy.response";
|
||||
|
||||
export class Policy extends Domain {
|
||||
id: PolicyId;
|
||||
organizationId: string;
|
||||
organizationId: OrganizationId;
|
||||
type: PolicyType;
|
||||
data: any;
|
||||
|
||||
@@ -26,7 +26,7 @@ export class Policy extends Domain {
|
||||
}
|
||||
|
||||
this.id = obj.id;
|
||||
this.organizationId = obj.organizationId;
|
||||
this.organizationId = obj.organizationId as OrganizationId;
|
||||
this.type = obj.type;
|
||||
this.data = obj.data;
|
||||
this.enabled = obj.enabled;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { PolicyType } from "../../enums";
|
||||
|
||||
export class PolicyRequest {
|
||||
export type PolicyRequest = {
|
||||
type: PolicyType;
|
||||
enabled: boolean;
|
||||
data: any;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ClientType } from "../../../../enums";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
|
||||
import { DeviceRequest } from "./device.request";
|
||||
import { TokenTwoFactorRequest } from "./token-two-factor.request";
|
||||
@@ -30,10 +29,6 @@ export class PasswordTokenRequest extends TokenRequest {
|
||||
return obj;
|
||||
}
|
||||
|
||||
alterIdentityTokenHeaders(headers: Headers) {
|
||||
headers.set("Auth-Email", Utils.fromUtf8ToUrlB64(this.email));
|
||||
}
|
||||
|
||||
static fromJSON(json: any) {
|
||||
return Object.assign(Object.create(PasswordTokenRequest.prototype), json, {
|
||||
device: json.device ? DeviceRequest.fromJSON(json.device) : undefined,
|
||||
|
||||
@@ -14,10 +14,6 @@ export abstract class TokenRequest {
|
||||
this.device = device != null ? device : null;
|
||||
}
|
||||
|
||||
alterIdentityTokenHeaders(headers: Headers) {
|
||||
// Implemented in subclass if required
|
||||
}
|
||||
|
||||
setTwoFactor(twoFactor: TokenTwoFactorRequest | undefined) {
|
||||
this.twoFactor = twoFactor;
|
||||
}
|
||||
|
||||
@@ -47,13 +47,10 @@ export enum FeatureFlag {
|
||||
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
|
||||
|
||||
/* Vault */
|
||||
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
EndUserNotifications = "pm-10609-end-user-notifications",
|
||||
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
|
||||
PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp",
|
||||
|
||||
@@ -94,10 +91,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
|
||||
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.EndUserNotifications]: FALSE,
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
|
||||
2
libs/common/src/key-management/crypto/index.ts
Normal file
2
libs/common/src/key-management/crypto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { KeyGenerationService } from "./key-generation/key-generation.service";
|
||||
export { DefaultKeyGenerationService } from "./key-generation/default-key-generation.service";
|
||||
@@ -0,0 +1,94 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
// 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 { KdfConfig } from "@bitwarden/key-management";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "../../../platform/enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
|
||||
import { KeyGenerationService } from "./key-generation.service";
|
||||
|
||||
export class DefaultKeyGenerationService implements KeyGenerationService {
|
||||
constructor(private cryptoFunctionService: CryptoFunctionService) {}
|
||||
|
||||
async createKey(bitLength: 256 | 512): Promise<SymmetricCryptoKey> {
|
||||
const key = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
|
||||
async createKeyWithPurpose(
|
||||
bitLength: 128 | 192 | 256 | 512,
|
||||
purpose: string,
|
||||
salt?: string,
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
|
||||
if (salt == null) {
|
||||
const bytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
salt = Utils.fromBufferToUtf8(bytes);
|
||||
}
|
||||
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
return { salt, material, derivedKey: new SymmetricCryptoKey(key) };
|
||||
}
|
||||
|
||||
async deriveKeyFromMaterial(
|
||||
material: CsprngArray,
|
||||
salt: string,
|
||||
purpose: string,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
|
||||
async deriveKeyFromPassword(
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
if (typeof password === "string") {
|
||||
password = new TextEncoder().encode(password);
|
||||
}
|
||||
if (typeof salt === "string") {
|
||||
salt = new TextEncoder().encode(salt);
|
||||
}
|
||||
|
||||
await SdkLoadService.Ready;
|
||||
return new SymmetricCryptoKey(
|
||||
PureCrypto.derive_kdf_material(password, salt, kdfConfig.toSdkConfig()),
|
||||
);
|
||||
}
|
||||
|
||||
async stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey> {
|
||||
// The key to be stretched is actually usually the output of a KDF, and not actually meant for AesCbc256_B64 encryption,
|
||||
// but has the same key length. Only 256-bit key materials should be stretched.
|
||||
if (key.inner().type != EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Key passed into stretchKey is not a 256-bit key.");
|
||||
}
|
||||
|
||||
const newKey = new Uint8Array(64);
|
||||
// Master key and pin key are always 32 bytes
|
||||
const encKey = await this.cryptoFunctionService.hkdfExpand(
|
||||
key.inner().encryptionKey,
|
||||
"enc",
|
||||
32,
|
||||
"sha256",
|
||||
);
|
||||
const macKey = await this.cryptoFunctionService.hkdfExpand(
|
||||
key.inner().encryptionKey,
|
||||
"mac",
|
||||
32,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
newKey.set(new Uint8Array(encKey));
|
||||
newKey.set(new Uint8Array(macKey), 32);
|
||||
|
||||
return new SymmetricCryptoKey(newKey);
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,21 @@ import { mock } from "jest-mock-extended";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { SdkLoadService } from "../abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "../enums";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "../../../platform/enums";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
|
||||
import { KeyGenerationService } from "./key-generation.service";
|
||||
import { DefaultKeyGenerationService } from "./default-key-generation.service";
|
||||
|
||||
describe("KeyGenerationService", () => {
|
||||
let sut: KeyGenerationService;
|
||||
let sut: DefaultKeyGenerationService;
|
||||
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new KeyGenerationService(cryptoFunctionService);
|
||||
sut = new DefaultKeyGenerationService(cryptoFunctionService);
|
||||
});
|
||||
|
||||
describe("createKey", () => {
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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 { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
|
||||
/**
|
||||
* @deprecated This is a low-level cryptographic service. New functionality should not be built
|
||||
* on top of it, and instead should be built in the sdk.
|
||||
*/
|
||||
export abstract class KeyGenerationService {
|
||||
/**
|
||||
* Generates a key of the given length suitable for use in AES encryption
|
||||
*
|
||||
* @deprecated WARNING: DO NOT USE THIS FOR NEW CODE. Direct generation and handling of keys should only be done in the SDK,
|
||||
* as memory safety cannot be ensured in a JS context.
|
||||
*
|
||||
* @param bitLength Length of key.
|
||||
* 256 bits = 32 bytes
|
||||
* 512 bits = 64 bytes
|
||||
* @returns Generated key.
|
||||
*/
|
||||
abstract createKey(bitLength: 256 | 512): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* Generates key material from CSPRNG and derives a 64 byte key from it.
|
||||
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869}
|
||||
* for details.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @param bitLength Length of key material.
|
||||
* @param purpose Purpose for the key derivation function.
|
||||
* Different purposes results in different keys, even with the same material.
|
||||
* @param salt Optional. If not provided will be generated from CSPRNG.
|
||||
* @returns An object containing the salt, key material, and derived key.
|
||||
*/
|
||||
abstract createKeyWithPurpose(
|
||||
bitLength: 128 | 192 | 256 | 512,
|
||||
purpose: string,
|
||||
salt?: string,
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>;
|
||||
/**
|
||||
* Derives a 64 byte key from key material.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}.
|
||||
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} for details.
|
||||
* @param material key material.
|
||||
* @param salt Salt for the key derivation function.
|
||||
* @param purpose Purpose for the key derivation function.
|
||||
* Different purposes results in different keys, even with the same material.
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract deriveKeyFromMaterial(
|
||||
material: CsprngArray,
|
||||
salt: string,
|
||||
purpose: string,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* Derives a 32 byte key from a password using a key derivation function.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @param password Password to derive the key from.
|
||||
* @param salt Salt for the key derivation function.
|
||||
* @param kdfConfig Configuration for the key derivation function.
|
||||
* @returns 32 byte derived key.
|
||||
*/
|
||||
abstract deriveKeyFromPassword(
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
|
||||
/**
|
||||
* Derives a 64 byte key from a 32 byte key using a key derivation function.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @param key 32 byte key.
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey>;
|
||||
}
|
||||
@@ -5,13 +5,12 @@ import { Jsonify } from "type-fest";
|
||||
import { EncString as SdkEncString } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../../platform/enums";
|
||||
import { Encrypted } from "../../../platform/interfaces/encrypted";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export const DECRYPT_ERROR = "[error: cannot decrypt]";
|
||||
|
||||
export class EncString implements Encrypted {
|
||||
export class EncString {
|
||||
encryptedString?: SdkEncString;
|
||||
encryptionType?: EncryptionType;
|
||||
decryptedValue?: string;
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { AbstractStorageService } from "../../../platform/abstractions/storage.service";
|
||||
@@ -30,6 +29,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
|
||||
import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey, DeviceKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
|
||||
@@ -25,7 +25,6 @@ import { DeviceType } from "../../../enums";
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { AbstractStorageService } from "../../../platform/abstractions/storage.service";
|
||||
@@ -37,6 +36,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { DeviceKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
|
||||
@@ -18,9 +18,9 @@ import { TokenService } from "../../../auth/services/token.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { KeyGenerationService } from "../../../platform/services/key-generation.service";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
|
||||
|
||||
@@ -23,13 +23,13 @@ import { Organization } from "../../../admin-console/models/domain/organization"
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { KeysRequest } from "../../../models/request/keys.request";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { KEY_CONNECTOR_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { MasterKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
|
||||
@@ -11,7 +11,6 @@ import { KdfConfig } from "@bitwarden/key-management";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { EncryptionType } from "../../../platform/enums";
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
} from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncryptedString, EncString } from "../../crypto/models/enc-string";
|
||||
|
||||
@@ -9,11 +9,11 @@ import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString, EncryptedString } from "../../key-management/crypto/models/enc-string";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { PIN_DISK, PIN_MEMORY, StateProvider, UserKeyDefinition } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { PinKey, UserKey } from "../../types/key";
|
||||
import { KeyGenerationService } from "../crypto";
|
||||
|
||||
import { PinServiceAbstraction } from "./pin.service.abstraction";
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { mock } from "jest-mock-extended";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { PinKey, UserKey } from "../../types/key";
|
||||
import { KeyGenerationService } from "../crypto";
|
||||
import { CryptoFunctionService } from "../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../crypto/models/enc-string";
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
// 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 { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { CollectionExport } from "./collection.export";
|
||||
|
||||
export class CollectionWithIdExport extends CollectionExport {
|
||||
id: string;
|
||||
id: CollectionId;
|
||||
|
||||
static toView(req: CollectionWithIdExport, view = new CollectionView()) {
|
||||
view.id = req.id;
|
||||
return super.toView(req, view);
|
||||
static toView(req: CollectionWithIdExport) {
|
||||
return super.toView(req, req.id);
|
||||
}
|
||||
|
||||
static toDomain(req: CollectionWithIdExport, domain = new CollectionDomain()) {
|
||||
static toDomain(req: CollectionWithIdExport, domain: CollectionDomain) {
|
||||
domain.id = req.id;
|
||||
return super.toDomain(req, domain);
|
||||
}
|
||||
|
||||
@@ -5,28 +5,30 @@
|
||||
import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
|
||||
|
||||
import { EncString } from "../../key-management/crypto/models/enc-string";
|
||||
import { CollectionId, emptyGuid, OrganizationId } from "../../types/guid";
|
||||
|
||||
import { safeGetString } from "./utils";
|
||||
|
||||
export class CollectionExport {
|
||||
static template(): CollectionExport {
|
||||
const req = new CollectionExport();
|
||||
req.organizationId = "00000000-0000-0000-0000-000000000000";
|
||||
req.organizationId = emptyGuid as OrganizationId;
|
||||
req.name = "Collection name";
|
||||
req.externalId = null;
|
||||
return req;
|
||||
}
|
||||
|
||||
static toView(req: CollectionExport, view = new CollectionView()) {
|
||||
view.name = req.name;
|
||||
static toView(req: CollectionExport, id: CollectionId) {
|
||||
const view = new CollectionView({
|
||||
name: req.name,
|
||||
organizationId: req.organizationId,
|
||||
id,
|
||||
});
|
||||
view.externalId = req.externalId;
|
||||
if (view.organizationId == null) {
|
||||
view.organizationId = req.organizationId;
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
static toDomain(req: CollectionExport, domain = new CollectionDomain()) {
|
||||
static toDomain(req: CollectionExport, domain: CollectionDomain) {
|
||||
domain.name = req.name != null ? new EncString(req.name) : null;
|
||||
domain.externalId = req.externalId;
|
||||
if (domain.organizationId == null) {
|
||||
@@ -35,7 +37,7 @@ export class CollectionExport {
|
||||
return domain;
|
||||
}
|
||||
|
||||
organizationId: string;
|
||||
organizationId: OrganizationId;
|
||||
name: string;
|
||||
externalId: string;
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ export abstract class ConfigApiServiceAbstraction {
|
||||
/**
|
||||
* Fetches the server configuration for the given user. If no user is provided, the configuration will not contain user-specific context.
|
||||
*/
|
||||
abstract get(userId: UserId | undefined): Promise<ServerConfigResponse>;
|
||||
abstract get(userId: UserId | null): Promise<ServerConfigResponse>;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,13 @@ export interface Environment {
|
||||
*/
|
||||
export abstract class EnvironmentService {
|
||||
abstract environment$: Observable<Environment>;
|
||||
|
||||
/**
|
||||
* The environment stored in global state, when a user signs in the state stored here will become
|
||||
* their user environment.
|
||||
*/
|
||||
abstract globalEnvironment$: Observable<Environment>;
|
||||
|
||||
abstract cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
/**
|
||||
@@ -125,12 +132,12 @@ export abstract class EnvironmentService {
|
||||
* @param userId - The user id to set the cloud web vault app URL for. If null or undefined the global environment is set.
|
||||
* @param region - The region of the cloud web vault app.
|
||||
*/
|
||||
abstract setCloudRegion(userId: UserId, region: Region): Promise<void>;
|
||||
abstract setCloudRegion(userId: UserId | null, region: Region): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the environment from state. Useful if you need to get the environment for another user.
|
||||
*/
|
||||
abstract getEnvironment$(userId: UserId): Observable<Environment | undefined>;
|
||||
abstract getEnvironment$(userId: UserId): Observable<Environment>;
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link getEnvironment$} instead.
|
||||
|
||||
@@ -1,66 +1,2 @@
|
||||
// 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 { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class KeyGenerationService {
|
||||
/**
|
||||
* Generates a key of the given length suitable for use in AES encryption
|
||||
* @param bitLength Length of key.
|
||||
* 256 bits = 32 bytes
|
||||
* 512 bits = 64 bytes
|
||||
* @returns Generated key.
|
||||
*/
|
||||
abstract createKey(bitLength: 256 | 512): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* Generates key material from CSPRNG and derives a 64 byte key from it.
|
||||
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869}
|
||||
* for details.
|
||||
* @param bitLength Length of key material.
|
||||
* @param purpose Purpose for the key derivation function.
|
||||
* Different purposes results in different keys, even with the same material.
|
||||
* @param salt Optional. If not provided will be generated from CSPRNG.
|
||||
* @returns An object containing the salt, key material, and derived key.
|
||||
*/
|
||||
abstract createKeyWithPurpose(
|
||||
bitLength: 128 | 192 | 256 | 512,
|
||||
purpose: string,
|
||||
salt?: string,
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>;
|
||||
/**
|
||||
* Derives a 64 byte key from key material.
|
||||
* @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}.
|
||||
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} for details.
|
||||
* @param material key material.
|
||||
* @param salt Salt for the key derivation function.
|
||||
* @param purpose Purpose for the key derivation function.
|
||||
* Different purposes results in different keys, even with the same material.
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract deriveKeyFromMaterial(
|
||||
material: CsprngArray,
|
||||
salt: string,
|
||||
purpose: string,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* Derives a 32 byte key from a password using a key derivation function.
|
||||
* @param password Password to derive the key from.
|
||||
* @param salt Salt for the key derivation function.
|
||||
* @param kdfConfig Configuration for the key derivation function.
|
||||
* @returns 32 byte derived key.
|
||||
*/
|
||||
abstract deriveKeyFromPassword(
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
|
||||
/**
|
||||
* Derives a 64 byte key from a 32 byte key using a key derivation function.
|
||||
* @param key 32 byte key.
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey>;
|
||||
}
|
||||
/** Temporary re-export. This should not be used for new imports */
|
||||
export { KeyGenerationService } from "../../key-management/crypto/key-generation/key-generation.service";
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { EncryptionType } from "../enums";
|
||||
|
||||
export interface Encrypted {
|
||||
encryptionType?: EncryptionType;
|
||||
dataBytes: Uint8Array;
|
||||
macBytes: Uint8Array;
|
||||
ivBytes: Uint8Array;
|
||||
}
|
||||
@@ -13,8 +13,8 @@ export const getById = <TId, T extends { id: TId }>(id: TId) =>
|
||||
* @param id The IDs of the objects to return.
|
||||
* @returns An array containing objects with matching IDs, or an empty array if there are no matching objects.
|
||||
*/
|
||||
export const getByIds = <TId, T extends { id: TId | undefined }>(ids: TId[]) => {
|
||||
const idSet = new Set(ids.filter((id) => id != null));
|
||||
export const getByIds = <TId, T extends { id: TId }>(ids: TId[]) => {
|
||||
const idSet = new Set(ids);
|
||||
return map<T[], T[]>((objects) => {
|
||||
return objects.filter((o) => o.id && idSet.has(o.id));
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export type DecryptedObject<
|
||||
> = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>;
|
||||
|
||||
// extracts shared keys from the domain and view types
|
||||
export type EncryptableKeys<D extends Domain, V extends View> = (keyof D &
|
||||
type EncryptableKeys<D extends Domain, V extends View> = (keyof D &
|
||||
ConditionalKeys<D, EncString | null>) &
|
||||
(keyof V & ConditionalKeys<V, string | null>);
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
// @ts-strict-ignore
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { Encrypted } from "../../interfaces/encrypted";
|
||||
|
||||
const ENC_TYPE_LENGTH = 1;
|
||||
const IV_LENGTH = 16;
|
||||
const MAC_LENGTH = 32;
|
||||
const MIN_DATA_LENGTH = 1;
|
||||
|
||||
export class EncArrayBuffer implements Encrypted {
|
||||
export class EncArrayBuffer {
|
||||
readonly encryptionType: EncryptionType = null;
|
||||
readonly dataBytes: Uint8Array = null;
|
||||
readonly ivBytes: Uint8Array = null;
|
||||
|
||||
@@ -10,7 +10,7 @@ export class ConfigApiService implements ConfigApiServiceAbstraction {
|
||||
private tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
async get(userId: UserId | undefined): Promise<ServerConfigResponse> {
|
||||
async get(userId: UserId | null): Promise<ServerConfigResponse> {
|
||||
// Authentication adds extra context to config responses, if the user has an access token, we want to use it
|
||||
// We don't particularly care about ensuring the token is valid and not expired, just that it exists
|
||||
const authed: boolean =
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
import { subscribeTo } from "../../../../spec/observable-tracker";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
@@ -74,7 +74,8 @@ describe("ConfigService", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
environmentService.environment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
@@ -98,9 +99,12 @@ describe("ConfigService", () => {
|
||||
: serverConfigFactory(activeApiUrl + userId, tooOld);
|
||||
const globalStored =
|
||||
configStateDescription === "missing"
|
||||
? {}
|
||||
? {
|
||||
[activeApiUrl]: null,
|
||||
}
|
||||
: {
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld),
|
||||
[activeApiUrl + "0"]: serverConfigFactory(activeApiUrl + userId, tooOld),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -108,11 +112,6 @@ describe("ConfigService", () => {
|
||||
userState.nextState(userStored);
|
||||
});
|
||||
|
||||
// sanity check
|
||||
test("authed and unauthorized state are different", () => {
|
||||
expect(globalStored[activeApiUrl]).not.toEqual(userStored);
|
||||
});
|
||||
|
||||
describe("fail to fetch", () => {
|
||||
beforeEach(() => {
|
||||
configApiService.get.mockRejectedValue(new Error("Unable to fetch"));
|
||||
@@ -178,6 +177,7 @@ describe("ConfigService", () => {
|
||||
beforeEach(() => {
|
||||
globalState.stateSubject.next(globalStored);
|
||||
userState.nextState(userStored);
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
});
|
||||
it("does not fetch from server", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
@@ -189,21 +189,13 @@ describe("ConfigService", () => {
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
});
|
||||
|
||||
it("does not complete after emit", async () => {
|
||||
const emissions = [];
|
||||
const subscription = sut.serverConfig$.subscribe((v) => emissions.push(v));
|
||||
await awaitAsync();
|
||||
expect(emissions.length).toBe(1);
|
||||
expect(subscription.closed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("gets global config when there is an locked active user", async () => {
|
||||
await accountService.switchAccount(userId);
|
||||
environmentService.environment$ = of(environmentFactory(activeApiUrl));
|
||||
environmentService.globalEnvironment$ = of(environmentFactory(activeApiUrl));
|
||||
|
||||
globalState.stateSubject.next({
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl + "global"),
|
||||
@@ -236,7 +228,8 @@ describe("ConfigService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
environmentSubject = new Subject<Environment>();
|
||||
environmentService.environment$ = environmentSubject;
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
@@ -327,7 +320,8 @@ describe("ConfigService", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
const config = serverConfigFactory("existing-data", tooOld);
|
||||
environmentService.environment$ = environmentSubject;
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
|
||||
globalState.stateSubject.next({ [apiUrl(0)]: config });
|
||||
userState.stateSubject.next({
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
map,
|
||||
mergeWith,
|
||||
NEVER,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
ReplaySubject,
|
||||
share,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
@@ -50,11 +51,15 @@ export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, A
|
||||
},
|
||||
);
|
||||
|
||||
const environmentComparer = (previous: Environment, current: Environment) => {
|
||||
return previous.getApiUrl() === current.getApiUrl();
|
||||
};
|
||||
|
||||
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it.
|
||||
export class DefaultConfigService implements ConfigService {
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig>();
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig | null>();
|
||||
|
||||
serverConfig$: Observable<ServerConfig>;
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
|
||||
serverSettings$: Observable<ServerSettings>;
|
||||
|
||||
@@ -67,32 +72,61 @@ export class DefaultConfigService implements ConfigService {
|
||||
private stateProvider: StateProvider,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
const userId$ = this.stateProvider.activeUserId$;
|
||||
const authStatus$ = userId$.pipe(
|
||||
switchMap((userId) => (userId == null ? of(null) : this.authService.authStatusFor$(userId))),
|
||||
const globalConfig$ = this.environmentService.globalEnvironment$.pipe(
|
||||
distinctUntilChanged(environmentComparer),
|
||||
switchMap((environment) =>
|
||||
this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map((config) => {
|
||||
return [config, null as UserId | null, environment, config] as const;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.serverConfig$ = combineLatest([
|
||||
userId$,
|
||||
this.environmentService.environment$,
|
||||
authStatus$,
|
||||
]).pipe(
|
||||
switchMap(([userId, environment, authStatus]) => {
|
||||
if (userId == null || authStatus !== AuthenticationStatus.Unlocked) {
|
||||
return this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map((config) => [config, null, environment] as const),
|
||||
);
|
||||
this.serverConfig$ = this.stateProvider.activeUserId$.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((userId) => {
|
||||
if (userId == null) {
|
||||
// Global
|
||||
return globalConfig$;
|
||||
}
|
||||
|
||||
return this.userConfigFor$(userId).pipe(
|
||||
map((config) => [config, userId, environment] as const),
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
|
||||
distinctUntilChanged(),
|
||||
switchMap((isUnlocked) => {
|
||||
if (!isUnlocked) {
|
||||
return globalConfig$;
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.environmentService
|
||||
.getEnvironment$(userId)
|
||||
.pipe(distinctUntilChanged(environmentComparer)),
|
||||
this.userConfigFor$(userId),
|
||||
]).pipe(
|
||||
switchMap(([environment, config]) => {
|
||||
if (config == null) {
|
||||
// If the user doesn't have any config yet, use the global config for that url as the fallback
|
||||
return this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map(
|
||||
(globalConfig) =>
|
||||
[null as ServerConfig | null, userId, environment, globalConfig] as const,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return of([config, userId, environment, config] as const);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
tap(async (rec) => {
|
||||
const [existingConfig, userId, environment] = rec;
|
||||
const [existingConfig, userId, environment, fallbackConfig] = rec;
|
||||
// Grab new config if older retrieval interval
|
||||
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
||||
await this.renewConfig(existingConfig, userId, environment);
|
||||
await this.renewConfig(existingConfig, userId, environment, fallbackConfig);
|
||||
}
|
||||
}),
|
||||
switchMap(([existingConfig]) => {
|
||||
@@ -106,7 +140,7 @@ export class DefaultConfigService implements ConfigService {
|
||||
}),
|
||||
// If fetch fails, we'll emit on this subject to fallback to the existing config
|
||||
mergeWith(this.failedFetchFallbackSubject),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: () => timer(1000) }),
|
||||
);
|
||||
|
||||
this.cloudRegion$ = this.serverConfig$.pipe(
|
||||
@@ -155,19 +189,18 @@ export class DefaultConfigService implements ConfigService {
|
||||
|
||||
// Updates the on-disk configuration with a newly retrieved configuration
|
||||
private async renewConfig(
|
||||
existingConfig: ServerConfig,
|
||||
userId: UserId,
|
||||
existingConfig: ServerConfig | null,
|
||||
userId: UserId | null,
|
||||
environment: Environment,
|
||||
fallbackConfig: ServerConfig | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Feature flags often have a big impact on user experience, lets ensure we return some value
|
||||
// somewhat quickly even though it may not be accurate, we won't cancel the HTTP request
|
||||
// though so that hopefully it can have finished and hydrated a more accurate value.
|
||||
const handle = setTimeout(() => {
|
||||
this.logService.info(
|
||||
"Self-host environment did not respond in time, emitting previous config.",
|
||||
);
|
||||
this.failedFetchFallbackSubject.next(existingConfig);
|
||||
this.logService.info("Environment did not respond in time, emitting previous config.");
|
||||
this.failedFetchFallbackSubject.next(fallbackConfig);
|
||||
}, SLOW_EMISSION_GUARD);
|
||||
const response = await this.configApiService.get(userId);
|
||||
clearTimeout(handle);
|
||||
@@ -195,17 +228,17 @@ export class DefaultConfigService implements ConfigService {
|
||||
// mutate error to be handled by catchError
|
||||
this.logService.error(`Unable to fetch ServerConfig from ${environment.getApiUrl()}`, e);
|
||||
// Emit the existing config
|
||||
this.failedFetchFallbackSubject.next(existingConfig);
|
||||
this.failedFetchFallbackSubject.next(fallbackConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> {
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig | null> {
|
||||
return this.stateProvider
|
||||
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl]));
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl] ?? null));
|
||||
}
|
||||
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig> {
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig | null> {
|
||||
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
);
|
||||
|
||||
environment$: Observable<Environment>;
|
||||
globalEnvironment$: Observable<Environment>;
|
||||
cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
@@ -148,6 +149,10 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId),
|
||||
);
|
||||
|
||||
this.globalEnvironment$ = this.stateProvider
|
||||
.getGlobal(GLOBAL_ENVIRONMENT_KEY)
|
||||
.state$.pipe(map((state) => this.buildEnvironment(state?.region, state?.urls)));
|
||||
|
||||
this.environment$ = account$.pipe(
|
||||
switchMap((userId) => {
|
||||
const t = userId
|
||||
@@ -263,7 +268,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
return new SelfHostedEnvironment(urls);
|
||||
}
|
||||
|
||||
async setCloudRegion(userId: UserId, region: CloudRegion) {
|
||||
async setCloudRegion(userId: UserId | null, region: CloudRegion) {
|
||||
if (userId == null) {
|
||||
await this.globalCloudRegionState.update(() => region);
|
||||
} else {
|
||||
@@ -271,7 +276,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
}
|
||||
}
|
||||
|
||||
getEnvironment$(userId: UserId): Observable<Environment | undefined> {
|
||||
getEnvironment$(userId: UserId): Observable<Environment> {
|
||||
return this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$.pipe(
|
||||
map((state) => {
|
||||
return this.buildEnvironment(state?.region, state?.urls);
|
||||
|
||||
@@ -1,92 +1,2 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// 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 { KdfConfig } from "@bitwarden/key-management";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "../abstractions/key-generation.service";
|
||||
import { SdkLoadService } from "../abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "../enums";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export class KeyGenerationService implements KeyGenerationServiceAbstraction {
|
||||
constructor(private cryptoFunctionService: CryptoFunctionService) {}
|
||||
|
||||
async createKey(bitLength: 256 | 512): Promise<SymmetricCryptoKey> {
|
||||
const key = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
|
||||
async createKeyWithPurpose(
|
||||
bitLength: 128 | 192 | 256 | 512,
|
||||
purpose: string,
|
||||
salt?: string,
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
|
||||
if (salt == null) {
|
||||
const bytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
salt = Utils.fromBufferToUtf8(bytes);
|
||||
}
|
||||
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
return { salt, material, derivedKey: new SymmetricCryptoKey(key) };
|
||||
}
|
||||
|
||||
async deriveKeyFromMaterial(
|
||||
material: CsprngArray,
|
||||
salt: string,
|
||||
purpose: string,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
|
||||
async deriveKeyFromPassword(
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
if (typeof password === "string") {
|
||||
password = new TextEncoder().encode(password);
|
||||
}
|
||||
if (typeof salt === "string") {
|
||||
salt = new TextEncoder().encode(salt);
|
||||
}
|
||||
|
||||
await SdkLoadService.Ready;
|
||||
return new SymmetricCryptoKey(
|
||||
PureCrypto.derive_kdf_material(password, salt, kdfConfig.toSdkConfig()),
|
||||
);
|
||||
}
|
||||
|
||||
async stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey> {
|
||||
// The key to be stretched is actually usually the output of a KDF, and not actually meant for AesCbc256_B64 encryption,
|
||||
// but has the same key length. Only 256-bit key materials should be stretched.
|
||||
if (key.inner().type != EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Key passed into stretchKey is not a 256-bit key.");
|
||||
}
|
||||
|
||||
const newKey = new Uint8Array(64);
|
||||
// Master key and pin key are always 32 bytes
|
||||
const encKey = await this.cryptoFunctionService.hkdfExpand(
|
||||
key.inner().encryptionKey,
|
||||
"enc",
|
||||
32,
|
||||
"sha256",
|
||||
);
|
||||
const macKey = await this.cryptoFunctionService.hkdfExpand(
|
||||
key.inner().encryptionKey,
|
||||
"mac",
|
||||
32,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
newKey.set(new Uint8Array(encKey));
|
||||
newKey.set(new Uint8Array(macKey), 32);
|
||||
|
||||
return new SymmetricCryptoKey(newKey);
|
||||
}
|
||||
}
|
||||
/** Temporary re-export. This should not be used for new imports */
|
||||
export { DefaultKeyGenerationService as KeyGenerationService } from "../../key-management/crypto/key-generation/default-key-generation.service";
|
||||
|
||||
@@ -197,7 +197,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
if (this.customUserAgent != null) {
|
||||
headers.set("User-Agent", this.customUserAgent);
|
||||
}
|
||||
request.alterIdentityTokenHeaders(headers);
|
||||
|
||||
const identityToken =
|
||||
request instanceof UserApiTokenRequest
|
||||
|
||||
@@ -20,3 +20,8 @@ export type OrganizationIntegrationConfigurationId = Opaque<
|
||||
string,
|
||||
"OrganizationIntegrationConfigurationId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A string representation of an empty guid.
|
||||
*/
|
||||
export const emptyGuid = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, map, of } from "rxjs";
|
||||
import { BehaviorSubject, filter, firstValueFrom, map, of } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -7,6 +7,7 @@ import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.r
|
||||
// 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 { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management";
|
||||
import { MessageSender } from "@bitwarden/messaging";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
@@ -106,6 +107,7 @@ describe("Cipher Service", () => {
|
||||
const logService = mock<LogService>();
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
const cipherEncryptionService = mock<CipherEncryptionService>();
|
||||
const messageSender = mock<MessageSender>();
|
||||
|
||||
const userId = "TestUserId" as UserId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
|
||||
@@ -134,6 +136,7 @@ describe("Cipher Service", () => {
|
||||
accountService,
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messageSender,
|
||||
);
|
||||
|
||||
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
|
||||
@@ -551,6 +554,23 @@ describe("Cipher Service", () => {
|
||||
newUserKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends overlay update when cipherViews$ emits", async () => {
|
||||
(cipherService.cipherViews$ as jest.Mock)?.mockRestore();
|
||||
|
||||
const decryptedView = new CipherView(encryptionContext.cipher);
|
||||
jest.spyOn(cipherService, "getAllDecrypted").mockResolvedValue([decryptedView]);
|
||||
|
||||
const sendSpy = jest.spyOn(messageSender, "send");
|
||||
|
||||
await firstValueFrom(
|
||||
cipherService
|
||||
.cipherViews$(mockUserId)
|
||||
.pipe(filter((cipherViews): cipherViews is CipherView[] => cipherViews != null)),
|
||||
);
|
||||
expect(sendSpy).toHaveBeenCalledWith("updateOverlayCiphers");
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -109,6 +119,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
private accountService: AccountService,
|
||||
private logService: LogService,
|
||||
private cipherEncryptionService: CipherEncryptionService,
|
||||
private messageSender: MessageSender,
|
||||
) {}
|
||||
|
||||
localData$(userId: UserId): Observable<Record<CipherId, LocalData>> {
|
||||
@@ -174,6 +185,10 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
]).pipe(
|
||||
filter(([ciphers, _, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
|
||||
switchMap(() => this.getAllDecrypted(userId)),
|
||||
tap(async (decrypted) => {
|
||||
await this.searchService.indexCiphers(userId, decrypted);
|
||||
this.messageSender.send("updateOverlayCiphers");
|
||||
}),
|
||||
);
|
||||
}, this.clearCipherViewsForUser$);
|
||||
|
||||
@@ -657,13 +672,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
async getManyFromApiForOrganization(organizationId: string): Promise<CipherView[]> {
|
||||
const response = await this.apiService.send(
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/ciphers/organization-details/assigned?organizationId=" + organizationId,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
return this.decryptOrganizationCiphersResponse(response, organizationId);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ describe("RestrictedItemTypesService", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits empty array if no account is active", async () => {
|
||||
accountService.activeAccount$ = of(null);
|
||||
|
||||
const result = await firstValueFrom(service.restricted$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits empty array if no organizations exist", async () => {
|
||||
organizationService.organizations$.mockReturnValue(of([]));
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
||||
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";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -32,39 +32,43 @@ export class RestrictedItemTypesService {
|
||||
return of([]);
|
||||
}
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
getOptionalUserId,
|
||||
switchMap((userId) => {
|
||||
if (userId == null) {
|
||||
return of([]); // No user logged in, no restrictions
|
||||
}
|
||||
return combineLatest([
|
||||
this.organizationService.organizations$(userId),
|
||||
this.policyService.policiesByType$(PolicyType.RestrictedItemTypes, userId),
|
||||
]),
|
||||
),
|
||||
map(([orgs, enabledPolicies]) => {
|
||||
// Helper to extract restricted types, defaulting to [Card]
|
||||
const restrictedTypes = (p: (typeof enabledPolicies)[number]) =>
|
||||
(p.data as CipherType[]) ?? [CipherType.Card];
|
||||
]).pipe(
|
||||
map(([orgs, enabledPolicies]) => {
|
||||
// Helper to extract restricted types, defaulting to [Card]
|
||||
const restrictedTypes = (p: (typeof enabledPolicies)[number]) =>
|
||||
(p.data as CipherType[]) ?? [CipherType.Card];
|
||||
|
||||
// Union across all enabled policies
|
||||
const allRestrictedTypes = Array.from(
|
||||
new Set(enabledPolicies.flatMap(restrictedTypes)),
|
||||
// Union across all enabled policies
|
||||
const allRestrictedTypes = Array.from(
|
||||
new Set(enabledPolicies.flatMap(restrictedTypes)),
|
||||
);
|
||||
|
||||
return allRestrictedTypes.map((cipherType) => {
|
||||
// Determine which orgs allow viewing this type
|
||||
const allowViewOrgIds = orgs
|
||||
.filter((org) => {
|
||||
const orgPolicy = enabledPolicies.find((p) => p.organizationId === org.id);
|
||||
// no policy for this org => allows everything
|
||||
if (!orgPolicy) {
|
||||
return true;
|
||||
}
|
||||
// if this type not in their restricted list => they allow it
|
||||
return !restrictedTypes(orgPolicy).includes(cipherType);
|
||||
})
|
||||
.map((org) => org.id);
|
||||
|
||||
return { cipherType, allowViewOrgIds };
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return allRestrictedTypes.map((cipherType) => {
|
||||
// Determine which orgs allow viewing this type
|
||||
const allowViewOrgIds = orgs
|
||||
.filter((org) => {
|
||||
const orgPolicy = enabledPolicies.find((p) => p.organizationId === org.id);
|
||||
// no policy for this org => allows everything
|
||||
if (!orgPolicy) {
|
||||
return true;
|
||||
}
|
||||
// if this type not in their restricted list => they allow it
|
||||
return !restrictedTypes(orgPolicy).includes(cipherType);
|
||||
})
|
||||
.map((org) => org.id);
|
||||
|
||||
return { cipherType, allowViewOrgIds };
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -157,6 +157,14 @@ export class AnonLayoutWrapperComponent implements OnInit {
|
||||
this.hideCardWrapper = data.hideCardWrapper;
|
||||
}
|
||||
|
||||
if (data.hideIcon !== undefined) {
|
||||
this.hideIcon = data.hideIcon;
|
||||
}
|
||||
|
||||
if (data.maxWidth !== undefined) {
|
||||
this.maxWidth = data.maxWidth;
|
||||
}
|
||||
|
||||
// Manually fire change detection to avoid ExpressionChangedAfterItHasBeenCheckedError
|
||||
// when setting the page data from a service
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
@@ -98,7 +98,7 @@ export default {
|
||||
<div class="tw-font-bold tw-mb-2">
|
||||
Secondary Projected Content (optional)
|
||||
</div>
|
||||
<button bitButton>Perform Action</button>
|
||||
<button type="button" bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
|
||||
@@ -35,7 +35,7 @@ const template = `
|
||||
<button class="tw-me-2" type="button" buttonType="secondary" bitButton bitFormButton>Cancel</button>
|
||||
<button class="tw-me-2" type="button" buttonType="danger" bitButton bitFormButton [bitAction]="delete">Delete</button>
|
||||
<button class="tw-me-2" type="button" buttonType="secondary" bitButton bitFormButton [disabled]="true">Disabled</button>
|
||||
<button class="tw-me-2" type="button" buttonType="secondary" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
|
||||
<button class="tw-me-2" type="button" buttonType="muted" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
|
||||
</form>`;
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -13,10 +13,10 @@ import { AsyncActionsModule } from "./async-actions.module";
|
||||
import { BitActionDirective } from "./bit-action.directive";
|
||||
|
||||
const template = /*html*/ `
|
||||
<button bitButton buttonType="primary" [bitAction]="action" class="tw-me-2">
|
||||
<button type="button" bitButton buttonType="primary" [bitAction]="action" class="tw-me-2">
|
||||
Perform action {{ statusEmoji }}
|
||||
</button>
|
||||
<button bitIconButton="bwi-trash" buttonType="danger" [bitAction]="action"></button>`;
|
||||
<button type="button" bitIconButton="bwi-trash" buttonType="danger" [bitAction]="action"></button>`;
|
||||
|
||||
@Component({
|
||||
template,
|
||||
|
||||
@@ -50,7 +50,7 @@ export class AvatarComponent implements OnChanges {
|
||||
}
|
||||
|
||||
get classList() {
|
||||
return ["tw-rounded-full"]
|
||||
return ["tw-rounded-full", "tw-inline"]
|
||||
.concat(SizeClasses[this.size()] ?? [])
|
||||
.concat(this.border() ? ["tw-border", "tw-border-solid", "tw-border-secondary-600"] : []);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Primary: Story = {
|
||||
<span class="tw-text-main">link </span><a href="#" bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<span class="tw-text-main">button </span><button bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge</button>
|
||||
<span class="tw-text-main">button </span><button type="button" bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -108,40 +108,40 @@ export const VariantsAndInteractionStates: Story = {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<span class="tw-text-main tw-mx-1">Default</span>
|
||||
<button class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<button type="button" class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button type="button" class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button type="button" class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button type="button" class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button type="button" class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button type="button" class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button type="button" class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Hover</span>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-hover" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-hover" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-hover" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Focus Visible</span>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-focus-visible" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-focus-visible" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button type="button" class="tw-mx-1 tw-test-focus-visible" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Disabled</span>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<button type="button" disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
<button type="button" disabled class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
|
||||
<button type="button" disabled class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
|
||||
<button type="button" disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button type="button" disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button type="button" disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button type="button" disabled class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
class="tw-flex tw-items-center tw-gap-4 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
[ngClass]="bannerClass"
|
||||
[attr.role]="useAlertRole() ? 'status' : null"
|
||||
[attr.aria-live]="useAlertRole() ? 'polite' : null"
|
||||
@@ -14,11 +14,10 @@
|
||||
<!-- Overriding hover and focus-visible colors for a11y against colored background -->
|
||||
@if (showClose()) {
|
||||
<button
|
||||
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
size="small"
|
||||
(click)="onClose.emit()"
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
|
||||
@@ -49,10 +49,10 @@ export const Base: Story = {
|
||||
render: (args) => {
|
||||
return {
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-banner ${formatArgsForCodeSnippet<BannerComponent>(args)}>
|
||||
Content Really Long Text Lorem Ipsum Ipsum Ipsum
|
||||
<button bitLink linkType="secondary">Button</button>
|
||||
<button type="button" bitLink linkType="secondary">Button</button>
|
||||
</bit-banner>
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<button bitButton ${formatArgsForCodeSnippet<ButtonComponent>(args)}>Button</button>
|
||||
<button type="button" bitButton ${formatArgsForCodeSnippet<ButtonComponent>(args)}>Button</button>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
@@ -58,9 +58,9 @@ export const Small: Story = {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'primary'" [size]="size" [block]="block">Primary small</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'secondary'" [size]="size" [block]="block">Secondary small</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'danger'" [size]="size" [block]="block">Danger small</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'primary'" [size]="size" [block]="block">Primary small</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'secondary'" [size]="size" [block]="block">Secondary small</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'danger'" [size]="size" [block]="block">Danger small</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -86,15 +86,15 @@ export const Disabled: Story = {
|
||||
export const DisabledWithAttribute: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
@if (disabled) {
|
||||
<button bitButton disabled [loading]="loading" [block]="block" buttonType="primary" class="tw-me-2">Primary</button>
|
||||
<button bitButton disabled [loading]="loading" [block]="block" buttonType="secondary" class="tw-me-2">Secondary</button>
|
||||
<button bitButton disabled [loading]="loading" [block]="block" buttonType="danger" class="tw-me-2">Danger</button>
|
||||
<button type="button" bitButton disabled [loading]="loading" [block]="block" buttonType="primary" class="tw-me-2">Primary</button>
|
||||
<button type="button" bitButton disabled [loading]="loading" [block]="block" buttonType="secondary" class="tw-me-2">Secondary</button>
|
||||
<button type="button" bitButton disabled [loading]="loading" [block]="block" buttonType="danger" class="tw-me-2">Danger</button>
|
||||
} @else {
|
||||
<button bitButton [loading]="loading" [block]="block" buttonType="primary" class="tw-me-2">Primary</button>
|
||||
<button bitButton [loading]="loading" [block]="block" buttonType="secondary" class="tw-me-2">Secondary</button>
|
||||
<button bitButton [loading]="loading" [block]="block" buttonType="danger" class="tw-me-2">Danger</button>
|
||||
<button type="button" bitButton [loading]="loading" [block]="block" buttonType="primary" class="tw-me-2">Primary</button>
|
||||
<button type="button" bitButton [loading]="loading" [block]="block" buttonType="secondary" class="tw-me-2">Secondary</button>
|
||||
<button type="button" bitButton [loading]="loading" [block]="block" buttonType="danger" class="tw-me-2">Danger</button>
|
||||
}
|
||||
`,
|
||||
}),
|
||||
@@ -107,12 +107,12 @@ export const DisabledWithAttribute: Story = {
|
||||
export const Block: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<span class="tw-flex">
|
||||
<button bitButton [buttonType]="buttonType" [block]="block">[block]="true" Button</button>
|
||||
<button type="button" bitButton [buttonType]="buttonType" [block]="block">[block]="true" Button</button>
|
||||
<a bitButton [buttonType]="buttonType" [block]="block" href="#" class="tw-ms-2">[block]="true" Link</a>
|
||||
|
||||
<button bitButton [buttonType]="buttonType" block class="tw-ms-2">block Button</button>
|
||||
<button type="button" bitButton [buttonType]="buttonType" block class="tw-ms-2">block Button</button>
|
||||
<a bitButton [buttonType]="buttonType" block href="#" class="tw-ms-2">block Link</a>
|
||||
</span>
|
||||
`,
|
||||
@@ -125,16 +125,16 @@ export const Block: Story = {
|
||||
export const WithIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<span class="tw-flex tw-gap-8">
|
||||
<div>
|
||||
<button bitButton [buttonType]="buttonType" [block]="block">
|
||||
<button type="button" bitButton [buttonType]="buttonType" [block]="block">
|
||||
<i class="bwi bwi-plus tw-me-2"></i>
|
||||
Button label
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button bitButton [buttonType]="buttonType" [block]="block">
|
||||
<button type="button" bitButton [buttonType]="buttonType" [block]="block">
|
||||
Button label
|
||||
<i class="bwi bwi-plus tw-ms-2"></i>
|
||||
</button>
|
||||
@@ -149,11 +149,11 @@ export const InteractionStates: Story = {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Button</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Button:hover</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
|
||||
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Button:active</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Button</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Button:hover</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
|
||||
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Button:active</button>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-items-center">
|
||||
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Anchor</a>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
type="button"
|
||||
[attr.aria-label]="'removeItem' | i18n: label"
|
||||
[disabled]="disabled"
|
||||
class="tw-bg-transparent hover:tw-bg-transparent tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-me-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent hover:tw-border-text-contrast hover:disabled:tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast focus-visible:hover:tw-border-transparent"
|
||||
class="tw-bg-transparent hover:tw-bg-hover-contrast tw-outline-none tw-rounded-full tw-py-0.5 tw-px-1 tw-me-1 tw-text-[color:inherit] tw-text-[length:inherit] tw-border-solid tw-border tw-border-transparent tw-flex tw-items-center tw-justify-center focus-visible:tw-ring-2 tw-ring-text-contrast hover:disabled:tw-bg-transparent"
|
||||
[ngClass]="{
|
||||
'tw-cursor-not-allowed': disabled,
|
||||
}"
|
||||
|
||||
@@ -2,14 +2,8 @@ import { Component, computed, HostBinding, input } from "@angular/core";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
enum CharacterType {
|
||||
Letter,
|
||||
Emoji,
|
||||
Special,
|
||||
Number,
|
||||
}
|
||||
type CharacterType = "letter" | "emoji" | "special" | "number";
|
||||
|
||||
/**
|
||||
* The color password is used primarily in the Generator pages and in the Login type form. It includes
|
||||
* the logic for displaying letters as `text-main`, numbers as `primary`, and special symbols as
|
||||
@@ -36,10 +30,10 @@ export class ColorPasswordComponent {
|
||||
});
|
||||
|
||||
characterStyles: Record<CharacterType, string[]> = {
|
||||
[CharacterType.Emoji]: [],
|
||||
[CharacterType.Letter]: ["tw-text-main"],
|
||||
[CharacterType.Special]: ["tw-text-danger"],
|
||||
[CharacterType.Number]: ["tw-text-primary-600"],
|
||||
emoji: [],
|
||||
letter: ["tw-text-main"],
|
||||
special: ["tw-text-danger"],
|
||||
number: ["tw-text-primary-600"],
|
||||
};
|
||||
|
||||
@HostBinding("class")
|
||||
@@ -68,18 +62,18 @@ export class ColorPasswordComponent {
|
||||
|
||||
private getCharacterType(character: string): CharacterType {
|
||||
if (character.match(Utils.regexpEmojiPresentation)) {
|
||||
return CharacterType.Emoji;
|
||||
return "emoji";
|
||||
}
|
||||
|
||||
if (character.match(/\d/)) {
|
||||
return CharacterType.Number;
|
||||
return "number";
|
||||
}
|
||||
|
||||
const specials = ["&", "<", ">", " "];
|
||||
if (specials.includes(character) || character.match(/[^\w ]/)) {
|
||||
return CharacterType.Special;
|
||||
return "special";
|
||||
}
|
||||
|
||||
return CharacterType.Letter;
|
||||
return "letter";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,10 @@ class StoryDialogContentComponent {
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<bit-dialog title="Dialog Title" dialogSize="large">
|
||||
<bit-dialog
|
||||
title="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore"
|
||||
dialogSize="large"
|
||||
>
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.
|
||||
<br />
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
@let isDrawer = dialogRef?.isDrawer;
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
|
||||
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl']"
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main"
|
||||
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl tw-shadow-lg']"
|
||||
@fadeIn
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@let showHeaderBorder = !isDrawer || background() === "alt" || bodyHasScrolledFrom().top;
|
||||
@let showHeaderBorder = bodyHasScrolledFrom().top;
|
||||
<header
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-py-3 tw-ps-6 tw-pe-4"
|
||||
[ngClass]="{
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-p-6 tw-pb-4': isDrawer,
|
||||
'tw-border-secondary-300': showHeaderBorder,
|
||||
'tw-p-4 has-[[biticonbutton]]:tw-pe-2': !isDrawer,
|
||||
'tw-px-6 tw-py-4 has-[[biticonbutton]]:tw-pe-4': isDrawer,
|
||||
'tw-border-secondary-100': showHeaderBorder,
|
||||
'tw-border-transparent': !showHeaderBorder,
|
||||
}"
|
||||
>
|
||||
@@ -59,25 +59,25 @@
|
||||
<div
|
||||
cdkScrollable
|
||||
[ngClass]="{
|
||||
'tw-p-4': !disablePadding() && !isDrawer,
|
||||
'tw-px-6 tw-py-4': !disablePadding() && isDrawer,
|
||||
'tw-py-2 tw-ps-6 tw-pe-6': !disablePadding(),
|
||||
'tw-overflow-y-auto': !loading(),
|
||||
'tw-invisible tw-overflow-y-hidden': loading(),
|
||||
'tw-py-4': background() === 'alt',
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogContent]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@let showFooterBorder = !isDrawer || background() === "alt" || bodyHasScrolledFrom().bottom;
|
||||
@let showFooterBorder =
|
||||
(!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom;
|
||||
<footer
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
|
||||
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-bg-background tw-py-5 tw-ps-6 tw-pe-4"
|
||||
[ngClass]="{
|
||||
'tw-px-6 tw-py-4': isDrawer,
|
||||
'tw-p-4': !isDrawer,
|
||||
'tw-border-secondary-300': showFooterBorder,
|
||||
'tw-p-4 has-[[biticonbutton]]:tw-pe-2': !isDrawer,
|
||||
'tw-border-transparent': !showFooterBorder,
|
||||
'tw-border-secondary-100': showFooterBorder,
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogFooter]"></ng-content>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostBinding, inject, viewChild, input, booleanAttribute } from "@angular/core";
|
||||
import {
|
||||
Component,
|
||||
HostBinding,
|
||||
inject,
|
||||
viewChild,
|
||||
input,
|
||||
booleanAttribute,
|
||||
AfterViewInit,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -31,10 +39,11 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
|
||||
CdkScrollable,
|
||||
],
|
||||
})
|
||||
export class DialogComponent {
|
||||
export class DialogComponent implements AfterViewInit {
|
||||
protected dialogRef = inject(DialogRef, { optional: true });
|
||||
private scrollableBody = viewChild.required(CdkScrollable);
|
||||
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
|
||||
protected isScrollable = false;
|
||||
|
||||
/** Background color */
|
||||
readonly background = input<"default" | "alt">("default");
|
||||
@@ -96,4 +105,13 @@ export class DialogComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.isScrollable = this.canScroll();
|
||||
}
|
||||
|
||||
canScroll(): boolean {
|
||||
const el = this.scrollableBody().getElementRef().nativeElement as HTMLElement;
|
||||
return el.scrollHeight > el.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@ export default {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
background: {
|
||||
options: ["alt", "default"],
|
||||
control: { type: "radio" },
|
||||
table: {
|
||||
defaultValue: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
@@ -78,16 +85,17 @@ type Story = StoryObj<DialogComponent & { title: string }>;
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-dialog [dialogSize]="dialogSize" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<ng-container bitDialogTitle>
|
||||
<span bitBadge variant="success">Foobar</span>
|
||||
</ng-container>
|
||||
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="loading"
|
||||
class="tw-ms-auto"
|
||||
bitIconButton="bwi-trash"
|
||||
@@ -142,8 +150,8 @@ export const Loading: Story = {
|
||||
export const ScrollingContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-dialog title="Scrolling Example" [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
|
||||
template: /*html*/ `
|
||||
<bit-dialog title="Scrolling Example" [background]="background" [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.<br />
|
||||
<ng-container *ngFor="let _ of [].constructor(100)">
|
||||
@@ -152,8 +160,8 @@ export const ScrollingContent: Story = {
|
||||
end of sequence!
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
`,
|
||||
@@ -166,8 +174,8 @@ export const ScrollingContent: Story = {
|
||||
export const TabContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-dialog title="Tab Content Example" [dialogSize]="dialogSize" [disablePadding]="disablePadding">
|
||||
template: /*html*/ `
|
||||
<bit-dialog title="Tab Content Example" [background]="background" [dialogSize]="dialogSize" [disablePadding]="disablePadding">
|
||||
<span bitDialogContent>
|
||||
<bit-tab-group>
|
||||
<bit-tab label="First Tab">First Tab Content</bit-tab>
|
||||
@@ -176,8 +184,8 @@ export const TabContent: Story = {
|
||||
</bit-tab-group>
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
`,
|
||||
@@ -204,14 +212,14 @@ export const WithCards: Story = {
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-dialog background="alt" [dialogSize]="dialogSize" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<ng-container bitDialogContent>
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">
|
||||
Foo
|
||||
</h2>
|
||||
<button bitIconButton="bwi-star" size="small" slot="end"></button>
|
||||
<button type="button" bitIconButton="bwi-star" size="small" slot="end"></button>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field>
|
||||
@@ -231,7 +239,7 @@ export const WithCards: Story = {
|
||||
<h2 bitTypography="h6">
|
||||
Bar
|
||||
</h2>
|
||||
<button bitIconButton="bwi-star" size="small" slot="end"></button>
|
||||
<button type="button" bitIconButton="bwi-star" size="small" slot="end"></button>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field>
|
||||
@@ -248,9 +256,10 @@ export const WithCards: Story = {
|
||||
</bit-section>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="loading"
|
||||
class="tw-ms-auto"
|
||||
bitIconButton="bwi-trash"
|
||||
@@ -267,5 +276,6 @@ export const WithCards: Story = {
|
||||
dialogSize: "default",
|
||||
title: "Default",
|
||||
subtitle: "Subtitle",
|
||||
background: "alt",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-my-4 tw-pb-6 tw-pt-8 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded-3xl tw-border tw-border-solid tw-border-secondary-100 tw-shadow-xl tw-bg-text-contrast tw-text-main"
|
||||
class="tw-my-4 tw-pb-6 tw-pt-8 tw-flex tw-max-h-screen tw-w-96 tw-max-w-90vw tw-flex-col tw-overflow-hidden tw-rounded-3xl tw-border tw-border-solid tw-border-secondary-100 tw-shadow-lg tw-bg-text-contrast tw-text-main"
|
||||
@fadeIn
|
||||
>
|
||||
<div class="tw-flex tw-px-6 tw-flex-col tw-items-center tw-gap-2 tw-text-center">
|
||||
|
||||
@@ -27,13 +27,13 @@ type Story = StoryObj<SimpleDialogComponent & { useDefaultIcon: boolean }>;
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-simple-dialog>
|
||||
<span bitDialogTitle>Alert Dialog</span>
|
||||
<span bitDialogContent>Message Content</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary">Yes</button>
|
||||
<button bitButton buttonType="secondary">No</button>
|
||||
<button type="button" bitButton buttonType="primary">Yes</button>
|
||||
<button type="button" bitButton buttonType="secondary">No</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
@@ -43,14 +43,14 @@ export const Default: Story = {
|
||||
export const CustomIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-simple-dialog>
|
||||
<i bitDialogIcon class="bwi bwi-star tw-text-3xl tw-text-success" aria-hidden="true"></i>
|
||||
<span bitDialogTitle>Premium Subscription Available</span>
|
||||
<span bitDialogContent> Message Content</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary">Yes</button>
|
||||
<button bitButton buttonType="secondary">No</button>
|
||||
<button type="button" bitButton buttonType="primary">Yes</button>
|
||||
<button type="button" bitButton buttonType="secondary">No</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
@@ -60,13 +60,13 @@ export const CustomIcon: Story = {
|
||||
export const HideIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-simple-dialog hideIcon>
|
||||
<span bitDialogTitle>Premium Subscription Available</span>
|
||||
<span bitDialogContent> Message Content</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary">Yes</button>
|
||||
<button bitButton buttonType="secondary">No</button>
|
||||
<button type="button" bitButton buttonType="primary">Yes</button>
|
||||
<button type="button" bitButton buttonType="secondary">No</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
@@ -76,7 +76,7 @@ export const HideIcon: Story = {
|
||||
export const ScrollingContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-simple-dialog>
|
||||
<span bitDialogTitle>Alert Dialog</span>
|
||||
<span bitDialogContent>
|
||||
@@ -87,8 +87,8 @@ export const ScrollingContent: Story = {
|
||||
end of sequence!
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary">Yes</button>
|
||||
<button bitButton buttonType="secondary">No</button>
|
||||
<button type="button" bitButton buttonType="primary">Yes</button>
|
||||
<button type="button" bitButton buttonType="secondary">No</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
@@ -101,13 +101,13 @@ export const ScrollingContent: Story = {
|
||||
export const TextOverflow: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
template: /*html*/ `
|
||||
<bit-simple-dialog>
|
||||
<span bitDialogTitle>Alert Dialogdialogdialogdialogdialogdialogdialogdialogdialogdialogdialogdialogdialog</span>
|
||||
<span bitDialogContent>Message Contentcontentcontentcontentcontentcontentcontentcontentcontentcontentcontent</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary">Yes</button>
|
||||
<button bitButton buttonType="secondary">No</button>
|
||||
<button type="button" bitButton buttonType="primary">Yes</button>
|
||||
<button type="button" bitButton buttonType="secondary">No</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<header class="tw-flex tw-justify-between tw-items-center">
|
||||
<div class="tw-flex tw-items-center tw-gap-1 tw-overflow-auto">
|
||||
<header class="tw-flex tw-justify-between tw-items-center tw-gap-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-overflow-auto">
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
<h2 bitTypography="h3" noMargin class="tw-text-main tw-mb-0 tw-truncate" [attr.title]="title()">
|
||||
{{ title() }}
|
||||
|
||||
@@ -50,13 +50,13 @@
|
||||
>
|
||||
<div
|
||||
#prefixContainer
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-ps-3 tw-py-2"
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-ps-3 has-[[biticonbutton]]:tw-ps-1 tw-py-1"
|
||||
[hidden]="!prefixHasChildren()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="prefixContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
class="tw-w-full tw-relative tw-py-2 has-[bit-select]:tw-p-0 has-[bit-multi-select]:tw-p-0 has-[input:read-only:not([hidden])]:tw-bg-secondary-100 has-[textarea:read-only:not([hidden])]:tw-bg-secondary-100"
|
||||
class="tw-w-full tw-relative tw-py-1 has-[bit-select]:tw-p-0 has-[bit-multi-select]:tw-p-0 has-[input:read-only:not([hidden])]:tw-bg-secondary-100 has-[textarea:read-only:not([hidden])]:tw-bg-secondary-100 has-[textarea]:tw-my-1"
|
||||
data-default-content
|
||||
[ngClass]="[
|
||||
prefixHasChildren() ? '' : 'tw-rounded-s-lg tw-ps-3',
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
<div
|
||||
#suffixContainer
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pe-3 tw-py-2"
|
||||
class="tw-flex tw-items-center tw-pe-3 has-[[biticonbutton]]:tw-pe-1 tw-py-1"
|
||||
[hidden]="!suffixHasChildren()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
|
||||
@@ -102,11 +102,7 @@
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
#suffixContainer
|
||||
[hidden]="!suffixHasChildren()"
|
||||
class="tw-flex tw-items-center tw-gap-1 tw-pe-1"
|
||||
>
|
||||
<div #suffixContainer [hidden]="!suffixHasChildren()" class="tw-flex tw-items-center tw-pe-1">
|
||||
<ng-container *ngTemplateOutlet="suffixContent"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -346,13 +346,11 @@ export const ButtonInputGroup: Story = {
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-label>
|
||||
<button bitPrefix bitIconButton="bwi-star" [appA11yTitle]="'Favorite Label'"></button>
|
||||
<button type="button" bitPrefix bitIconButton="bwi-star" [appA11yTitle]="'Favorite Label'"></button>
|
||||
<input bitInput placeholder="Placeholder" />
|
||||
<button bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink>
|
||||
Apply
|
||||
</button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" [appA11yTitle]="'Menu Label'"></button>
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
@@ -365,13 +363,12 @@ export const DisabledButtonInputGroup: Story = {
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<button bitPrefix bitIconButton="bwi-star" disabled [appA11yTitle]="'Favorite Label'"></button>
|
||||
<button type="button" bitPrefix bitIconButton="bwi-star" disabled [appA11yTitle]="'Favorite Label'"></button>
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
<button bitSuffix bitIconButton="bwi-eye" disabled [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" disabled [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink disabled>
|
||||
Apply
|
||||
</button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-eye" disabled [appA11yTitle]="'Hide Label'"></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-clone" disabled [appA11yTitle]="'Clone Label'"></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled [appA11yTitle]="'Menu Label'"></button>
|
||||
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
@@ -385,11 +382,9 @@ export const PartiallyDisabledButtonInputGroup: Story = {
|
||||
<bit-form-field>
|
||||
<bit-label>Label</bit-label>
|
||||
<input bitInput placeholder="Placeholder" disabled />
|
||||
<button bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button bitSuffix bitLink disabled>
|
||||
Apply
|
||||
</button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-eye" [appA11yTitle]="'Hide Label'"></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-clone" [appA11yTitle]="'Clone Label'"></button>
|
||||
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled [appA11yTitle]="'Menu Label'"></button>
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<span class="tw-relative">
|
||||
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
|
||||
<span class="tw-relative tw-inline-block tw-leading-[0px]">
|
||||
<span class="tw-inline-block tw-leading-[0px]" [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
|
||||
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Component, computed, ElementRef, HostBinding, input, model } from "@ang
|
||||
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { debounce, interval } from "rxjs";
|
||||
|
||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
|
||||
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
|
||||
|
||||
const focusRing = [
|
||||
// Workaround for box-shadow with transparent offset issue:
|
||||
@@ -20,7 +20,7 @@ const focusRing = [
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:-tw-inset-[2px]",
|
||||
"before:-tw-inset-[1px]",
|
||||
"before:tw-rounded-lg",
|
||||
"before:tw-transition",
|
||||
"before:tw-ring-2",
|
||||
@@ -30,122 +30,38 @@ const focusRing = [
|
||||
|
||||
const styles: Record<IconButtonType, string[]> = {
|
||||
contrast: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-contrast",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-text-contrast",
|
||||
"hover:!tw-bg-hover-contrast",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
...focusRing,
|
||||
],
|
||||
main: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-main",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
main: ["!tw-text-main", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
muted: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-muted",
|
||||
"tw-border-transparent",
|
||||
"aria-expanded:tw-bg-text-muted",
|
||||
"aria-expanded:!tw-text-contrast",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
"aria-expanded:hover:tw-bg-secondary-700",
|
||||
"aria-expanded:hover:tw-border-secondary-700",
|
||||
...focusRing,
|
||||
],
|
||||
primary: [
|
||||
"tw-bg-primary-600",
|
||||
"!tw-text-contrast",
|
||||
"tw-border-primary-600",
|
||||
"hover:tw-bg-primary-600",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
secondary: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-muted",
|
||||
"tw-border-text-muted",
|
||||
"hover:!tw-text-contrast",
|
||||
"hover:tw-bg-text-muted",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
danger: [
|
||||
"tw-bg-transparent",
|
||||
"!tw-text-danger-600",
|
||||
"tw-border-transparent",
|
||||
"hover:!tw-text-danger-600",
|
||||
"hover:tw-bg-transparent",
|
||||
"hover:tw-border-primary-600",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
...focusRing,
|
||||
],
|
||||
light: [
|
||||
"tw-bg-transparent",
|
||||
primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
"nav-contrast": [
|
||||
"!tw-text-alt2",
|
||||
"tw-border-transparent",
|
||||
"hover:tw-bg-transparent-hover",
|
||||
"hover:tw-border-text-alt2",
|
||||
"hover:!tw-bg-hover-contrast",
|
||||
"focus-visible:before:tw-ring-text-alt2",
|
||||
...focusRing,
|
||||
],
|
||||
unstyled: [],
|
||||
};
|
||||
|
||||
const disabledStyles: Record<IconButtonType, string[]> = {
|
||||
contrast: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
main: [
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
muted: [
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
primary: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-primary-600",
|
||||
"disabled:hover:tw-bg-primary-600",
|
||||
],
|
||||
secondary: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-text-muted",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
"disabled:hover:!tw-text-muted",
|
||||
],
|
||||
danger: [
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
"disabled:hover:!tw-text-secondary-300",
|
||||
],
|
||||
light: [
|
||||
"disabled:tw-opacity-60",
|
||||
"disabled:hover:tw-border-transparent",
|
||||
"disabled:hover:tw-bg-transparent",
|
||||
],
|
||||
unstyled: [],
|
||||
};
|
||||
|
||||
export type IconButtonSize = "default" | "small";
|
||||
|
||||
const sizes: Record<IconButtonSize, string[]> = {
|
||||
default: ["tw-px-2.5", "tw-py-1.5"],
|
||||
small: ["tw-leading-none", "tw-text-base", "tw-p-1"],
|
||||
default: ["tw-text-xl", "tw-p-2.5", "tw-rounded-md"],
|
||||
small: ["tw-text-base", "tw-p-2", "tw-rounded"],
|
||||
};
|
||||
/**
|
||||
* Icon buttons are used when no text accompanies the button. It consists of an icon that may be updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`.
|
||||
@@ -164,6 +80,13 @@ const sizes: Record<IconButtonSize, string[]> = {
|
||||
imports: [NgClass],
|
||||
host: {
|
||||
"[attr.disabled]": "disabledAttr()",
|
||||
/**
|
||||
* When the `bitIconButton` input is dynamic from a consumer, Angular doesn't put the
|
||||
* `bitIconButton` attribute into the DOM. We use the attribute as a css selector in
|
||||
* a number of components, so this manual attr binding makes sure that the css selector
|
||||
* works when the input is dynamic.
|
||||
*/
|
||||
"[attr.bitIconButton]": "icon()",
|
||||
},
|
||||
})
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||
@@ -176,17 +99,20 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-font-semibold",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-rounded-lg",
|
||||
"tw-leading-[0px]",
|
||||
"tw-border-none",
|
||||
"tw-transition",
|
||||
"tw-bg-transparent",
|
||||
"hover:tw-no-underline",
|
||||
"hover:tw-bg-hover-default",
|
||||
"focus:tw-outline-none",
|
||||
]
|
||||
.concat(styles[this.buttonType()])
|
||||
.concat(sizes[this.size()])
|
||||
.concat(
|
||||
this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType()] : [],
|
||||
this.showDisabledStyles() || this.disabled()
|
||||
? ["disabled:tw-opacity-60", "disabled:hover:!tw-bg-transparent"]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user