1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 00:03:56 +00:00

Merge branch 'master' into feature/org-admin-refresh

This commit is contained in:
Vincent Salucci
2022-09-07 10:46:42 -05:00
151 changed files with 4231 additions and 1148 deletions

View File

@@ -68,7 +68,7 @@ export class AvatarComponent implements OnChanges, OnInit {
}
const charObj = this.getCharText(chars);
const color = this.stringToColor(upperData);
const color = Utils.stringToColor(upperData);
const svg = this.getSvg(this.size, color);
svg.appendChild(charObj);
const html = window.document.createElement("div").appendChild(svg).outerHTML;
@@ -77,19 +77,6 @@ export class AvatarComponent implements OnChanges, OnInit {
}
}
private stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += ("00" + value.toString(16)).substr(-2);
}
return color;
}
private getFirstLetters(data: string, count: number): string {
const parts = data.split(" ");
if (parts.length > 1) {

View File

@@ -1,8 +1,10 @@
import Substitute, { Arg } from "@fluffy-spoon/substitute";
import { mock, MockProxy } from "jest-mock-extended";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { AttachmentData } from "@bitwarden/common/models/data/attachmentData";
import { Attachment } from "@bitwarden/common/models/domain/attachment";
import { EncString } from "@bitwarden/common/models/domain/encString";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
import { ContainerService } from "@bitwarden/common/services/container.service";
@@ -54,30 +56,79 @@ describe("Attachment", () => {
expect(attachment.toAttachmentData()).toEqual(data);
});
it("Decrypt", async () => {
const attachment = new Attachment();
attachment.id = "id";
attachment.url = "url";
attachment.size = "1100";
attachment.sizeName = "1.1 KB";
attachment.key = mockEnc("key");
attachment.fileName = mockEnc("fileName");
describe("decrypt", () => {
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<AbstractEncryptService>;
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
cryptoService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(32));
beforeEach(() => {
cryptoService = mock<CryptoService>();
encryptService = mock<AbstractEncryptService>();
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
});
const view = await attachment.decrypt(null);
it("expected output", async () => {
const attachment = new Attachment();
attachment.id = "id";
attachment.url = "url";
attachment.size = "1100";
attachment.sizeName = "1.1 KB";
attachment.key = mockEnc("key");
attachment.fileName = mockEnc("fileName");
expect(view).toEqual({
id: "id",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "fileName",
key: expect.any(SymmetricCryptoKey),
encryptService.decryptToBytes.mockResolvedValue(makeStaticByteArray(32));
const view = await attachment.decrypt(null);
expect(view).toEqual({
id: "id",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "fileName",
key: expect.any(SymmetricCryptoKey),
});
});
describe("decrypts attachment.key", () => {
let attachment: Attachment;
beforeEach(() => {
attachment = new Attachment();
attachment.key = mock<EncString>();
});
it("uses the provided key without depending on CryptoService", async () => {
const providedKey = mock<SymmetricCryptoKey>();
await attachment.decrypt(null, providedKey);
expect(cryptoService.getKeyForUserEncryption).not.toHaveBeenCalled();
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, providedKey);
});
it("gets an organization key if required", async () => {
const orgKey = mock<SymmetricCryptoKey>();
cryptoService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey);
await attachment.decrypt("orgId", null);
expect(cryptoService.getOrgKey).toHaveBeenCalledWith("orgId");
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, orgKey);
});
it("gets the user's decryption key if required", async () => {
const userKey = mock<SymmetricCryptoKey>();
cryptoService.getKeyForUserEncryption.mockResolvedValue(userKey);
await attachment.decrypt(null, null);
expect(cryptoService.getKeyForUserEncryption).toHaveBeenCalled();
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, userKey);
});
});
});
});

View File

@@ -1,5 +1,7 @@
import Substitute, { Arg } from "@fluffy-spoon/substitute";
import { mock, MockProxy } from "jest-mock-extended";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { EncryptionType } from "@bitwarden/common/enums/encryptionType";
import { EncString } from "@bitwarden/common/models/domain/encString";
@@ -48,10 +50,15 @@ describe("EncString", () => {
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
cryptoService.decryptToUtf8(encString, Arg.any()).resolves("decrypted");
const encryptService = Substitute.for<AbstractEncryptService>();
encryptService.decryptToUtf8(encString, Arg.any()).resolves("decrypted");
beforeEach(() => {
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
});
it("decrypts correctly", async () => {
@@ -62,7 +69,7 @@ describe("EncString", () => {
it("result should be cached", async () => {
const decrypted = await encString.decrypt(null);
cryptoService.received(1).decryptToUtf8(Arg.any(), Arg.any());
encryptService.received(1).decryptToUtf8(Arg.any(), Arg.any());
expect(decrypted).toBe("decrypted");
});
@@ -148,25 +155,28 @@ describe("EncString", () => {
});
describe("decrypt", () => {
it("throws exception when bitwarden container not initialized", async () => {
const encString = new EncString(null);
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<AbstractEncryptService>;
let encString: EncString;
expect.assertions(1);
try {
await encString.decrypt(null);
} catch (e) {
expect(e.message).toEqual("global bitwardenContainerService not initialized.");
}
beforeEach(() => {
cryptoService = mock<CryptoService>();
encryptService = mock<AbstractEncryptService>();
encString = new EncString(null);
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
});
it("handles value it can't decrypt", async () => {
const encString = new EncString(null);
encryptService.decryptToUtf8.mockRejectedValue("error");
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
cryptoService.decryptToUtf8(encString, Arg.any()).throws("error");
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
const decrypted = await encString.decrypt(null);
@@ -178,18 +188,35 @@ describe("EncString", () => {
});
});
it("passes along key", async () => {
const encString = new EncString(null);
const key = Substitute.for<SymmetricCryptoKey>();
const cryptoService = Substitute.for<CryptoService>();
cryptoService.getOrgKey(null).resolves(null);
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
it("uses provided key without depending on CryptoService", async () => {
const key = mock<SymmetricCryptoKey>();
await encString.decrypt(null, key);
cryptoService.received().decryptToUtf8(encString, key);
expect(cryptoService.getKeyForUserEncryption).not.toHaveBeenCalled();
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, key);
});
it("gets an organization key if required", async () => {
const orgKey = mock<SymmetricCryptoKey>();
cryptoService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey);
await encString.decrypt("orgId", null);
expect(cryptoService.getOrgKey).toHaveBeenCalledWith("orgId");
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, orgKey);
});
it("gets the user's decryption key if required", async () => {
const userKey = mock<SymmetricCryptoKey>();
cryptoService.getKeyForUserEncryption.mockResolvedValue(userKey);
await encString.decrypt(null, null);
expect(cryptoService.getKeyForUserEncryption).toHaveBeenCalledWith();
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, userKey);
});
});

View File

@@ -1,5 +1,6 @@
import Substitute, { Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { SendType } from "@bitwarden/common/enums/sendType";
import { SendData } from "@bitwarden/common/models/data/sendData";
@@ -110,7 +111,9 @@ describe("Send", () => {
cryptoService.decryptToBytes(send.key, null).resolves(makeStaticByteArray(32));
cryptoService.makeSendKey(Arg.any()).resolves("cryptoKey" as any);
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
const encryptService = Substitute.for<AbstractEncryptService>();
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
const view = await send.decrypt();

View File

@@ -1,6 +1,7 @@
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@@ -15,6 +16,7 @@ describe("Folder Service", () => {
let folderService: FolderService;
let cryptoService: SubstituteOf<CryptoService>;
let encryptService: SubstituteOf<AbstractEncryptService>;
let i18nService: SubstituteOf<I18nService>;
let cipherService: SubstituteOf<CipherService>;
let stateService: SubstituteOf<StateService>;
@@ -23,6 +25,7 @@ describe("Folder Service", () => {
beforeEach(() => {
cryptoService = Substitute.for();
encryptService = Substitute.for();
i18nService = Substitute.for();
cipherService = Substitute.for();
stateService = Substitute.for();
@@ -34,7 +37,7 @@ describe("Folder Service", () => {
});
stateService.activeAccount$.returns(activeAccount);
stateService.activeAccountUnlocked$.returns(activeAccountUnlocked);
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
folderService = new FolderService(cryptoService, i18nService, cipherService, stateService);
});

View File

@@ -1,6 +1,7 @@
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { ContainerService } from "@bitwarden/common/services/container.service";
import { SettingsService } from "@bitwarden/common/services/settings.service";
@@ -10,12 +11,14 @@ describe("SettingsService", () => {
let settingsService: SettingsService;
let cryptoService: SubstituteOf<CryptoService>;
let encryptService: SubstituteOf<AbstractEncryptService>;
let stateService: SubstituteOf<StateService>;
let activeAccount: BehaviorSubject<string>;
let activeAccountUnlocked: BehaviorSubject<boolean>;
beforeEach(() => {
cryptoService = Substitute.for();
encryptService = Substitute.for();
stateService = Substitute.for();
activeAccount = new BehaviorSubject("123");
activeAccountUnlocked = new BehaviorSubject(true);
@@ -23,7 +26,7 @@ describe("SettingsService", () => {
stateService.getSettings().resolves({ equivalentDomains: [["test"], ["domains"]] });
stateService.activeAccount$.returns(activeAccount);
stateService.activeAccountUnlocked$.returns(activeAccountUnlocked);
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
settingsService = new SettingsService(stateService);
});

View File

@@ -8,6 +8,7 @@ import { CollectionData } from "../models/data/collectionData";
import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData";
import { EventData } from "../models/data/eventData";
import { FolderData } from "../models/data/folderData";
import { LocalData } from "../models/data/localData";
import { OrganizationData } from "../models/data/organizationData";
import { PolicyData } from "../models/data/policyData";
import { ProviderData } from "../models/data/providerData";
@@ -245,8 +246,11 @@ export abstract class StateService<T extends Account = Account> {
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
getLastSync: (options?: StorageOptions) => Promise<string>;
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
getLocalData: (options?: StorageOptions) => Promise<any>;
setLocalData: (value: string, options?: StorageOptions) => Promise<void>;
getLocalData: (options?: StorageOptions) => Promise<{ [cipherId: string]: LocalData }>;
setLocalData: (
value: { [cipherId: string]: LocalData },
options?: StorageOptions
) => Promise<void>;
getLocale: (options?: StorageOptions) => Promise<string>;
setLocale: (value: string, options?: StorageOptions) => Promise<void>;
getMainWindowSize: (options?: StorageOptions) => Promise<number>;

View File

@@ -3,6 +3,7 @@ import * as tldjs from "tldjs";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { AbstractEncryptService } from "../abstractions/abstractEncrypt.service";
import { I18nService } from "../abstractions/i18n.service";
const nodeURL = typeof window === "undefined" ? require("url") : null;
@@ -14,6 +15,7 @@ declare global {
interface BitwardenContainerService {
getCryptoService: () => CryptoService;
getEncryptService: () => AbstractEncryptService;
}
export class Utils {
@@ -368,6 +370,49 @@ export class Utils {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* There are a few ways to calculate text color for contrast, this one seems to fit accessibility guidelines best.
* https://stackoverflow.com/a/3943023/6869691
*
* @param {string} bgColor
* @param {number} [threshold] see stackoverflow link above
* @param {boolean} [svgTextFill]
* Indicates if this method is performed on an SVG <text> 'fill' attribute (e.g. <text fill="black"></text>).
* This check is necessary because the '!important' tag cannot be used in a 'fill' attribute.
*/
static pickTextColorBasedOnBgColor(bgColor: string, threshold = 186, svgTextFill = false) {
const bgColorHexNums = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
const r = parseInt(bgColorHexNums.substring(0, 2), 16); // hexToR
const g = parseInt(bgColorHexNums.substring(2, 4), 16); // hexToG
const b = parseInt(bgColorHexNums.substring(4, 6), 16); // hexToB
const blackColor = svgTextFill ? "black" : "black !important";
const whiteColor = svgTextFill ? "white" : "white !important";
return r * 0.299 + g * 0.587 + b * 0.114 > threshold ? blackColor : whiteColor;
}
static stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += ("00" + value.toString(16)).substr(-2);
}
return color;
}
/**
* @throws Will throw an error if the ContainerService has not been attached to the window object
*/
static getContainerService(): BitwardenContainerService {
if (this.global.bitwardenContainerService == null) {
throw new Error("global bitwardenContainerService not initialized.");
}
return this.global.bitwardenContainerService;
}
private static validIpAddress(ipString: string): boolean {
const ipRegex =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;

View File

@@ -0,0 +1,4 @@
export type LocalData = {
lastUsedDate?: number;
lastLaunched?: number;
};

View File

@@ -1,4 +1,3 @@
import { CryptoService } from "../../abstractions/crypto.service";
import { Utils } from "../../misc/utils";
import { AttachmentData } from "../data/attachmentData";
import { AttachmentView } from "../view/attachmentView";
@@ -47,26 +46,33 @@ export class Attachment extends Domain {
);
if (this.key != null) {
let cryptoService: CryptoService;
const containerService = Utils.global.bitwardenContainerService;
if (containerService) {
cryptoService = containerService.getCryptoService();
} else {
throw new Error("global bitwardenContainerService not initialized.");
}
try {
const orgKey = await cryptoService.getOrgKey(orgId);
const decValue = await cryptoService.decryptToBytes(this.key, orgKey ?? encKey);
view.key = new SymmetricCryptoKey(decValue);
} catch (e) {
// TODO: error?
}
view.key = await this.decryptAttachmentKey(orgId, encKey);
}
return view;
}
private async decryptAttachmentKey(orgId: string, encKey?: SymmetricCryptoKey) {
try {
if (encKey == null) {
encKey = await this.getKeyForDecryption(orgId);
}
const encryptService = Utils.getContainerService().getEncryptService();
const decValue = await encryptService.decryptToBytes(this.key, encKey);
return new SymmetricCryptoKey(decValue);
} catch (e) {
// TODO: error?
}
}
private async getKeyForDecryption(orgId: string) {
const cryptoService = Utils.getContainerService().getCryptoService();
return orgId != null
? await cryptoService.getOrgKey(orgId)
: await cryptoService.getKeyForUserEncryption();
}
toAttachmentData(): AttachmentData {
const a = new AttachmentData();
a.size = this.size;

View File

@@ -1,6 +1,7 @@
import { CipherRepromptType } from "../../enums/cipherRepromptType";
import { CipherType } from "../../enums/cipherType";
import { CipherData } from "../data/cipherData";
import { LocalData } from "../data/localData";
import { CipherView } from "../view/cipherView";
import { Attachment } from "./attachment";
@@ -26,7 +27,7 @@ export class Cipher extends Domain {
edit: boolean;
viewPassword: boolean;
revisionDate: Date;
localData: any;
localData: LocalData;
login: Login;
identity: Identity;
card: Card;
@@ -38,7 +39,7 @@ export class Cipher extends Domain {
deletedDate: Date;
reprompt: CipherRepromptType;
constructor(obj?: CipherData, localData: any = null) {
constructor(obj?: CipherData, localData: LocalData = null) {
super();
if (obj == null) {
return;

View File

@@ -2,7 +2,6 @@ import { Jsonify } from "type-fest";
import { IEncrypted } from "@bitwarden/common/interfaces/IEncrypted";
import { CryptoService } from "../../abstractions/crypto.service";
import { EncryptionType } from "../../enums/encryptionType";
import { Utils } from "../../misc/utils";
@@ -29,30 +28,6 @@ export class EncString implements IEncrypted {
}
}
async decrypt(orgId: string, key: SymmetricCryptoKey = null): Promise<string> {
if (this.decryptedValue != null) {
return this.decryptedValue;
}
let cryptoService: CryptoService;
const containerService = Utils.global.bitwardenContainerService;
if (containerService) {
cryptoService = containerService.getCryptoService();
} else {
throw new Error("global bitwardenContainerService not initialized.");
}
try {
if (key == null) {
key = await cryptoService.getOrgKey(orgId);
}
this.decryptedValue = await cryptoService.decryptToUtf8(this, key);
} catch (e) {
this.decryptedValue = "[error: cannot decrypt]";
}
return this.decryptedValue;
}
get ivBytes(): ArrayBuffer {
return this.iv == null ? null : Utils.fromB64ToArray(this.iv).buffer;
}
@@ -160,4 +135,32 @@ export class EncString implements IEncrypted {
encPieces,
};
}
async decrypt(orgId: string, key: SymmetricCryptoKey = null): Promise<string> {
if (this.decryptedValue != null) {
return this.decryptedValue;
}
try {
if (key == null) {
key = await this.getKeyForDecryption(orgId);
}
if (key == null) {
throw new Error("No key to decrypt EncString with orgId " + orgId);
}
const encryptService = Utils.getContainerService().getEncryptService();
this.decryptedValue = await encryptService.decryptToUtf8(this, key);
} catch (e) {
this.decryptedValue = "[error: cannot decrypt]";
}
return this.decryptedValue;
}
private async getKeyForDecryption(orgId: string) {
const cryptoService = Utils.getContainerService().getCryptoService();
return orgId != null
? await cryptoService.getOrgKey(orgId)
: await cryptoService.getKeyForUserEncryption();
}
}

View File

@@ -1,4 +1,3 @@
import { CryptoService } from "../../abstractions/crypto.service";
import { SendType } from "../../enums/sendType";
import { Utils } from "../../misc/utils";
import { SendData } from "../data/sendData";
@@ -71,13 +70,7 @@ export class Send extends Domain {
async decrypt(): Promise<SendView> {
const model = new SendView(this);
let cryptoService: CryptoService;
const containerService = Utils.global.bitwardenContainerService;
if (containerService) {
cryptoService = containerService.getCryptoService();
} else {
throw new Error("global bitwardenContainerService not initialized.");
}
const cryptoService = Utils.getContainerService().getCryptoService();
try {
model.key = await cryptoService.decryptToBytes(this.key, null);

View File

@@ -3,6 +3,7 @@ import { Jsonify } from "type-fest";
import { CipherRepromptType } from "../../enums/cipherRepromptType";
import { CipherType } from "../../enums/cipherType";
import { LinkedIdType } from "../../enums/linkedIdType";
import { LocalData } from "../data/localData";
import { Cipher } from "../domain/cipher";
import { AttachmentView } from "./attachmentView";
@@ -25,7 +26,7 @@ export class CipherView implements View {
organizationUseTotp = false;
edit = false;
viewPassword = true;
localData: any;
localData: LocalData;
login = new LoginView();
identity = new IdentityView();
card = new CardView();

View File

@@ -1,7 +1,11 @@
import { AbstractEncryptService } from "../abstractions/abstractEncrypt.service";
import { CryptoService } from "../abstractions/crypto.service";
export class ContainerService {
constructor(private cryptoService: CryptoService) {}
constructor(
private cryptoService: CryptoService,
private encryptService: AbstractEncryptService
) {}
attachToGlobal(global: any) {
if (!global.bitwardenContainerService) {
@@ -9,7 +13,23 @@ export class ContainerService {
}
}
/**
* @throws Will throw if CryptoService was not instantiated and provided to the ContainerService constructor
*/
getCryptoService(): CryptoService {
if (this.cryptoService == null) {
throw new Error("ContainerService.cryptoService not initialized.");
}
return this.cryptoService;
}
/**
* @throws Will throw if EncryptService was not instantiated and provided to the ContainerService constructor
*/
getEncryptService(): AbstractEncryptService {
if (this.encryptService == null) {
throw new Error("ContainerService.encryptService not initialized.");
}
return this.encryptService;
}
}

View File

@@ -11,6 +11,7 @@ export class I18nService implements I18nServiceAbstraction {
collator: Intl.Collator;
localeNames = new Map<string, string>([
["af", "Afrikaans"],
["ar", "العربية الفصحى"],
["az", "Azərbaycanca"],
["be", "Беларуская"],
["bg", "български"],

View File

@@ -16,6 +16,7 @@ import { CollectionData } from "../models/data/collectionData";
import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData";
import { EventData } from "../models/data/eventData";
import { FolderData } from "../models/data/folderData";
import { LocalData } from "../models/data/localData";
import { OrganizationData } from "../models/data/organizationData";
import { PolicyData } from "../models/data/policyData";
import { ProviderData } from "../models/data/providerData";
@@ -1747,12 +1748,16 @@ export class StateService<
);
}
async getLocalData(options?: StorageOptions): Promise<any> {
async getLocalData(options?: StorageOptions): Promise<{ [cipherId: string]: LocalData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.data?.localData;
}
async setLocalData(value: string, options?: StorageOptions): Promise<void> {
async setLocalData(
value: { [cipherId: string]: LocalData },
options?: StorageOptions
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);

View File

@@ -1,6 +1,7 @@
{
"name": "@bitwarden/components",
"version": "0.0.0",
"sideEffects": false,
"scripts": {
"ng": "ng",
"start": "ng serve",

View File

@@ -0,0 +1,127 @@
import { Component, Input, OnChanges } from "@angular/core";
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
import { Utils } from "@bitwarden/common/misc/utils";
type SizeTypes = "large" | "default" | "small";
const SizeClasses: Record<SizeTypes, string[]> = {
large: ["tw-h-16", "tw-w-16"],
default: ["tw-h-12", "tw-w-12"],
small: ["tw-h-7", "tw-w-7"],
};
@Component({
selector: "bit-avatar",
template: `<img *ngIf="src" [src]="src" title="{{ text }}" [ngClass]="classList" />`,
})
export class AvatarComponent implements OnChanges {
@Input() border = false;
@Input() color: string;
@Input() id: number;
@Input() text: string;
@Input() size: SizeTypes = "default";
private svgCharCount = 2;
private svgFontSize = 20;
private svgFontWeight = 300;
private svgSize = 48;
src: SafeResourceUrl;
constructor(public sanitizer: DomSanitizer) {}
ngOnChanges() {
this.generate();
}
get classList() {
return ["tw-rounded-full"]
.concat(SizeClasses[this.size] ?? [])
.concat(this.border ? ["tw-border", "tw-border-solid", "tw-border-secondary-500"] : []);
}
private generate() {
let chars: string = null;
const upperCaseText = this.text.toUpperCase();
chars = this.getFirstLetters(upperCaseText, this.svgCharCount);
if (chars == null) {
chars = this.unicodeSafeSubstring(upperCaseText, this.svgCharCount);
}
// If the chars contain an emoji, only show it.
if (chars.match(Utils.regexpEmojiPresentation)) {
chars = chars.match(Utils.regexpEmojiPresentation)[0];
}
let svg: HTMLElement;
let hexColor = this.color;
if (this.color != null) {
svg = this.createSvgElement(this.svgSize, hexColor);
} else if (this.id != null) {
hexColor = Utils.stringToColor(this.id.toString());
svg = this.createSvgElement(this.svgSize, hexColor);
} else {
hexColor = Utils.stringToColor(upperCaseText);
svg = this.createSvgElement(this.svgSize, hexColor);
}
const charObj = this.createTextElement(chars, hexColor);
svg.appendChild(charObj);
const html = window.document.createElement("div").appendChild(svg).outerHTML;
const svgHtml = window.btoa(unescape(encodeURIComponent(html)));
this.src = this.sanitizer.bypassSecurityTrustResourceUrl(
"data:image/svg+xml;base64," + svgHtml
);
}
private getFirstLetters(data: string, count: number): string {
const parts = data.split(" ");
if (parts.length > 1) {
let text = "";
for (let i = 0; i < count; i++) {
text += this.unicodeSafeSubstring(parts[i], 1);
}
return text;
}
return null;
}
private createSvgElement(size: number, color: string): HTMLElement {
const svgTag = window.document.createElement("svg");
svgTag.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svgTag.setAttribute("pointer-events", "none");
svgTag.setAttribute("width", size.toString());
svgTag.setAttribute("height", size.toString());
svgTag.style.backgroundColor = color;
svgTag.style.width = size + "px";
svgTag.style.height = size + "px";
return svgTag;
}
private createTextElement(character: string, color: string): HTMLElement {
const textTag = window.document.createElement("text");
textTag.setAttribute("text-anchor", "middle");
textTag.setAttribute("y", "50%");
textTag.setAttribute("x", "50%");
textTag.setAttribute("dy", "0.35em");
textTag.setAttribute("pointer-events", "auto");
textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true));
textTag.setAttribute(
"font-family",
'"Open Sans","Helvetica Neue",Helvetica,Arial,' +
'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"'
);
textTag.textContent = character;
textTag.style.fontWeight = this.svgFontWeight.toString();
textTag.style.fontSize = this.svgFontSize + "px";
return textTag;
}
private unicodeSafeSubstring(str: string, count: number) {
const characters = str.match(/./gu);
return characters != null ? characters.slice(0, count).join("") : "";
}
}

View File

@@ -0,0 +1,11 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { AvatarComponent } from "./avatar.component";
@NgModule({
imports: [CommonModule],
exports: [AvatarComponent],
declarations: [AvatarComponent],
})
export class AvatarModule {}

View File

@@ -0,0 +1,60 @@
import { Meta, Story } from "@storybook/angular";
import { AvatarComponent } from "./avatar.component";
export default {
title: "Component Library/Avatar",
component: AvatarComponent,
args: {
text: "Walt Walterson",
size: "default",
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A16994",
},
},
} as Meta;
const Template: Story<AvatarComponent> = (args: AvatarComponent) => ({
props: args,
});
export const Default = Template.bind({});
Default.args = {
color: "#175ddc",
};
export const Large = Template.bind({});
Large.args = {
...Default.args,
size: "large",
};
export const Small = Template.bind({});
Small.args = {
...Default.args,
size: "small",
};
export const LightBackground = Template.bind({});
LightBackground.args = {
color: "#d2ffcf",
};
export const Border = Template.bind({});
Border.args = {
...Default.args,
border: true,
};
export const ColorByID = Template.bind({});
ColorByID.args = {
id: 236478,
};
export const ColorByText = Template.bind({});
ColorByText.args = {
text: "Jason Doe",
};

View File

@@ -0,0 +1,2 @@
export * from "./avatar.module";
export * from "./avatar.component";

View File

@@ -1,7 +1,7 @@
import { Component, HostBinding, Input } from "@angular/core";
import { DomSanitizer } from "@angular/platform-browser";
import { Icon, IconSvg } from "./icons";
import { Icon, isIcon } from "./icon";
@Component({
selector: "bit-icon",
@@ -14,11 +14,11 @@ export class BitIconComponent {
@HostBinding("innerHtml")
protected get innerHtml() {
const svg = IconSvg[this.icon];
if (svg == null) {
return "Unknown icon";
if (!isIcon(this.icon)) {
return "";
}
return this.domSanitizer.bypassSecurityTrustHtml(IconSvg[this.icon]);
const svg = this.icon.svg;
return this.domSanitizer.bypassSecurityTrustHtml(svg);
}
}

View File

@@ -0,0 +1,39 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Icon, svgIcon } from "./icon";
import { BitIconComponent } from "./icon.component";
describe("IconComponent", () => {
let component: BitIconComponent;
let fixture: ComponentFixture<BitIconComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [BitIconComponent],
}).compileComponents();
fixture = TestBed.createComponent(BitIconComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should have empty innerHtml when input is not an Icon", () => {
const fakeIcon = { svg: "harmful user input" } as Icon;
component.icon = fakeIcon;
fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement;
expect(el.innerHTML).toBe("");
});
it("should contain icon when input is a safe Icon", () => {
const icon = svgIcon`<svg><text x="0" y="15">safe icon</text></svg>`;
component.icon = icon;
fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement;
expect(el.innerHTML).toBe(`<svg><text x="0" y="15">safe icon</text></svg>`);
});
});

View File

@@ -0,0 +1,38 @@
import * as IconExports from "./icon";
import { DynamicContentNotAllowedError, isIcon, svgIcon } from "./icon";
describe("Icon", () => {
it("exports should not expose Icon class", () => {
expect(Object.keys(IconExports)).not.toContain("Icon");
});
describe("isIcon", () => {
it("should return true when input is icon", () => {
const result = isIcon(svgIcon`icon`);
expect(result).toBe(true);
});
it("should return false when input is not an icon", () => {
const result = isIcon({ svg: "not an icon" });
expect(result).toBe(false);
});
});
describe("template literal", () => {
it("should throw when attempting to create dynamic icons", () => {
const dynamic = "some user input";
const f = () => svgIcon`static and ${dynamic}`;
expect(f).toThrow(DynamicContentNotAllowedError);
});
it("should return svg content when supplying icon with svg string", () => {
const icon = svgIcon`safe static content`;
expect(icon.svg).toBe("safe static content");
});
});
});

View File

@@ -0,0 +1,25 @@
class Icon {
constructor(readonly svg: string) {}
}
// We only export the type to prohibit the creation of Icons without using
// the `svgIcon` template literal tag.
export type { Icon };
export function isIcon(icon: unknown): icon is Icon {
return icon instanceof Icon;
}
export class DynamicContentNotAllowedError extends Error {
constructor() {
super("Dynamic content in icons is not allowed due to risk of user-injected XSS.");
}
}
export function svgIcon(strings: TemplateStringsArray, ...values: unknown[]): Icon {
if (values.length > 0) {
throw new DynamicContentNotAllowedError();
}
return new Icon(strings[0]);
}

View File

@@ -1,63 +0,0 @@
export const IconSvg = {
reportExposedPasswords: `
<svg width="101" height="77" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.374 50.192a26.42 26.42 0 0 0 9.111 1.608c14.34 0 25.965-11.372 25.965-25.4 0-.337-.007-.673-.02-1.008h25.299v34.85H32.374v-10.05Z" fill="currentColor" />
<path d="M15.805 26.4c0 14.028 11.625 25.4 25.965 25.4s25.964-11.372 25.964-25.4C67.734 12.372 56.11 1 41.77 1 27.43 1 15.805 12.372 15.805 26.4Z" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M27.914 47.849a1 1 0 0 0-2 0h2Zm68.288-26.792a2.12 2.12 0 0 1 2.14 2.11h2c0-2.253-1.83-4.11-4.14-4.11v2Zm2.14 2.11v40.552h2V23.167h-2Zm0 40.552c0 1.172-.958 2.11-2.14 2.11v2c2.25 0 4.14-1.798 4.14-4.11h-2Zm-2.14 2.11H30.054v2h66.148v-2Zm-66.148 0a2.12 2.12 0 0 1-2.14-2.11h-2a4.12 4.12 0 0 0 4.14 4.11v-2Zm-2.14-2.11V47.85h-2v15.87h2Zm39.254-42.662h29.034v-2H67.168v2Z" fill="#fff" />
<path d="M67.203 25.56h25.64v34.85H32.487V50.011" stroke="#fff" stroke-width="2" stroke-linejoin="round" />
<path d="M47.343 76h31.571" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M57.557 66.83V76M67.771 66.83V76" stroke="#fff" stroke-width="2" stroke-linejoin="round" />
<path d="m20.995 42.873-3.972 3.972-14.61 14.61a3.413 3.413 0 0 0 0 4.826v0a3.413 3.413 0 0 0 4.827 0l14.61-14.61 3.972-3.972" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M86.037 32.488H71.845M86.037 37.81H76.28M71.845 37.81h-6.652M86.037 43.132h-6.209M74.95 43.132H61.2M86.037 48.454H71.845M66.967 48.454h-7.54M86.037 53.776H66.08M61.201 53.776h-11.53M44.793 53.776h-7.096" stroke="#fff" stroke-width="2" stroke-linecap="round" />
<rect width="40.801" height="9.757" rx="4" transform="matrix(-1 0 0 1 61.201 14.748)" stroke="#fff" stroke-width="2" />
<path d="M16.852 33.375h28.375a4 4 0 0 1 4 4v1.757a4 4 0 0 1-4 4H22.174M66.523 33.375h-3.539a4 4 0 0 0-4 4v3.761c0 1.102.894 1.996 1.996 1.996v0" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
`,
reportReusedPasswords: `
<svg width="102" height="102" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M57.983 15.06a35.664 35.664 0 0 1 14.531 6.27c16.164 11.78 19.585 34.613 7.643 51a37.227 37.227 0 0 1-6.81 7.138m-32.842 6.697a35.708 35.708 0 0 1-11.239-5.495c-16.163-11.78-19.585-34.613-7.642-51a37.55 37.55 0 0 1 3.295-3.929" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M93.909 64.598H7.72c-.708 0-1.275-.662-1.275-1.49V40.273c0-.828.567-1.49 1.275-1.49H93.91c.708 0 1.275.663 1.275 1.49v22.837c.047.827-.567 1.49-1.275 1.49Z" fill="currentColor" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M21.532 52.186v-5.965M21.532 52.187l5.748-1.844M21.532 52.186l3.524 4.881M21.531 52.186l-3.47 4.881M21.532 52.187l-5.694-1.844M40.944 52.186v-5.965M40.944 52.187l5.694-1.844M40.944 52.187l3.525 4.88M40.944 52.187l-3.525 4.88M40.944 52.187l-5.694-1.844M54.849 57.337h11.294M74.21 57.337h11.295M41.75 83l.71 4.75-4.75.71M58.664 18.66 56 14.665 59.996 12" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
reportWeakPasswords: `
<svg width="78" height="78" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M66.493 64.415V77H9.979V64.324M9.979 44.065V32.106h56.514v12.148" stroke="#fff" stroke-width="2" stroke-linejoin="round" />
<path d="M75.44 64.852H2.085c-.603 0-1.085-.555-1.085-1.25V44.448c0-.694.482-1.25 1.085-1.25H75.44c.603 0 1.085.556 1.085 1.25v19.156c.04.694-.482 1.25-1.085 1.25Z" fill="currentColor" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M13.84 54.56v-5.077M13.84 54.56l4.893-1.57M13.84 54.56l3 4.153M13.84 54.56l-2.954 4.153M13.84 54.56l-4.846-1.57M30.363 54.56v-5.077M30.363 54.56l4.846-1.57M30.363 54.56l3 4.153M30.363 54.56l-3 4.153M30.363 54.56l-4.846-1.57M42.197 59.042h9.506M58.57 59.042h9.507" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M20.863 31.364c-.274-5.285 0-15.817 1.093-18.863 1.276-3.554 6.233-10.826 15.856-11.482 4.83-.273 15.2 2.296 18.043 14.763" stroke="#fff" stroke-width="2" />
</svg>
`,
reportUnsecuredWebsites: `
<svg width="113" height="76" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.71 12.983h110.362v55.11a6 6 0 0 1-6 6H7.711a6 6 0 0 1-6-6v-55.11Z" fill="currentColor" />
<rect x="1" y="1.073" width="110.5" height="73.454" rx="9" stroke="#fff" stroke-width="2" />
<path d="M89.48 8.048V7.47M96.363 8.048V7.47M103.246 8.048V7.47" stroke="#fff" stroke-width="4" stroke-linecap="round" />
<path d="M0 12.983h111.217" stroke="#fff" stroke-width="2" />
<path d="m93.236 44.384-18.42-11.026 2.93 21.266 5.582-5.237 4.27 6.46 2.98-1.971-4.26-6.446 6.918-3.046Z" fill="#175DDC" stroke="#fff" stroke-width="2" stroke-linejoin="round" />
<rect width="96.673" height="6.886" rx="3.443" transform="matrix(-1 0 0 1 104.373 18.721)" stroke="#fff" />
</svg>
`,
reportInactiveTwoFactor: `
<svg width="42" height="75" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" stroke="#fff" stroke-width="2" d="M1 13.121h39.595v48.758H1z" />
<rect x="1" y="1" width="39.595" height="73" rx="8" stroke="#fff" stroke-width="2" />
<path stroke="#fff" stroke-width="2" stroke-linecap="round" d="M12.344 8.091h16.907M18.907 67.424h3.025M31.503 32.515c-2.047-4.337-6.717-7.061-11.73-6.414a11.356 11.356 0 0 0-9.125 7.126M10.816 42.016c2.047 4.337 6.718 7.062 11.73 6.414 4.346-.562 7.8-3.51 9.213-7.358" />
<path d="m33.584 29.293-1.295 4.625-4.625-1.295M8.523 44.725l1.441-4.581 4.582 1.441" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
reportBreach: `
<svg width="58" height="75" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M39.569 74H13.007a7 7 0 0 1-7-7V31.077a7 7 0 0 1 7-7h19.101a7 7 0 0 1 4.988 2.088l7.46 7.576a7 7 0 0 1 2.013 4.912V67a7 7 0 0 1-7 7Z" fill="#175DDC" stroke="#fff" stroke-width="2" />
<path d="M44.576 69.055H18.015a7 7 0 0 1-7-7V26.132a7 7 0 0 1 7-7h19.1a7 7 0 0 1 4.988 2.088l7.46 7.576a7 7 0 0 1 2.013 4.911v28.348a7 7 0 0 1-7 7Z" fill="#175DDC" stroke="#fff" stroke-width="2" />
<path d="M50 63.698H23.439a7 7 0 0 1-7-7V20.775a7 7 0 0 1 7-7h19.1a7 7 0 0 1 4.988 2.088l7.46 7.575A7 7 0 0 1 57 28.35v28.348a7 7 0 0 1-7 7Z" fill="#175DDC" stroke="#fff" stroke-width="2" />
<path d="M44.648 13.599v3.95a8 8 0 0 0 8 8h4.518" stroke="#fff" stroke-width="2" />
<path stroke="#fff" stroke-width="2" stroke-linecap="round" d="M23.533 37.736H49.49M23.533 46.802H49.49M23.533 42.269H49.49M23.533 55.456H49.49M23.533 50.923H49.49" />
<path d="M1 16.483C1 7.944 8.013 1 16.69 1c8.678 0 15.691 6.944 15.691 15.483 0 8.54-7.013 15.484-15.69 15.484C8.012 31.967 1 25.023 1 16.484Z" fill="#518FFF" stroke="#fff" stroke-width="2" />
<path d="m16.562 7.979.1 11.538" stroke="#fff" stroke-width="2" stroke-linecap="round" />
<ellipse rx="1.252" ry="1.236" transform="rotate(-.479 2802.219 -1964.476) skewX(.012)" fill="#fff" />
</svg>
`,
};
export type Icon = keyof typeof IconSvg;

View File

@@ -0,0 +1,4 @@
// Put generic icons in this folder and export them here.
// Note: Icons need to be in separate files for tree-shaking to work properly
export {}; // <- remove when adding icons in here

View File

@@ -1 +1,3 @@
export * from "./icon.module";
export * from "./icon";
export * as Icons from "./icons";