1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 14:34:02 +00:00

Merge branch 'master' into PS55-6-22

This commit is contained in:
CarleyDiaz-Bitwarden
2022-07-21 17:10:45 -04:00
431 changed files with 13919 additions and 5118 deletions

View File

@@ -1,10 +1,11 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Observable } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { EventService } from "@bitwarden/common/abstractions/event.service";
import { FolderService } from "@bitwarden/common/abstractions/folder.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
@@ -51,7 +52,7 @@ export class AddEditComponent implements OnInit {
editMode = false;
cipher: CipherView;
folders: FolderView[];
folders$: Observable<FolderView[]>;
collections: CollectionView[] = [];
title: string;
formPromise: Promise<any>;
@@ -109,6 +110,7 @@ export class AddEditComponent implements OnInit {
{ name: "Maestro", value: "Maestro" },
{ name: "UnionPay", value: "UnionPay" },
{ name: "RuPay", value: "RuPay" },
{ name: i18nService.t("cardBrandMir"), value: "Mir" },
{ name: i18nService.t("other"), value: "Other" },
];
this.cardExpMonthOptions = [
@@ -243,7 +245,7 @@ export class AddEditComponent implements OnInit {
}
}
this.folders = await this.folderService.getAllDecrypted();
this.folders$ = this.folderService.folderViews$;
if (this.editMode && this.previousCipherId !== this.cipherId) {
this.eventService.collect(EventType.Cipher_ClientViewed, this.cipherId);

View File

@@ -1,6 +1,7 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { FolderService } from "@bitwarden/common/abstractions/folder.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/abstractions/folder/folder-api.service.abstraction";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
@@ -20,6 +21,7 @@ export class FolderAddEditComponent implements OnInit {
constructor(
protected folderService: FolderService,
protected folderApiService: FolderApiServiceAbstraction,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
private logService: LogService
@@ -41,7 +43,7 @@ export class FolderAddEditComponent implements OnInit {
try {
const folder = await this.folderService.encrypt(this.folder);
this.formPromise = this.folderService.saveWithServer(folder);
this.formPromise = this.folderApiService.save(folder);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
@@ -70,7 +72,7 @@ export class FolderAddEditComponent implements OnInit {
}
try {
this.deletePromise = this.folderService.deleteWithServer(this.folder.id);
this.deletePromise = this.folderApiService.delete(this.folder.id);
await this.deletePromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("deletedFolder"));
this.onDeletedFolder.emit(this.folder);

View File

@@ -20,6 +20,7 @@ const cardIcons: Record<string, string> = {
Maestro: "card-maestro",
UnionPay: "card-union-pay",
RuPay: "card-ru-pay",
Mir: "card-mir",
};
@Component({

View File

@@ -1,16 +1,23 @@
import { Directive, OnInit } from "@angular/core";
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { AbstractControl, FormBuilder, ValidatorFn, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { InputsFieldMatch } from "@bitwarden/angular/validators/inputsFieldMatch.validator";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import {
AllValidationErrors,
FormValidationErrorsService,
} from "@bitwarden/common/abstractions/formValidationErrors.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { DEFAULT_KDF_ITERATIONS, DEFAULT_KDF_TYPE } from "@bitwarden/common/enums/kdfType";
import { PasswordLogInCredentials } from "@bitwarden/common/models/domain/logInCredentials";
import { KeysRequest } from "@bitwarden/common/models/request/keysRequest";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/referenceEventRequest";
import { RegisterRequest } from "@bitwarden/common/models/request/registerRequest";
@@ -19,22 +26,48 @@ import { CaptchaProtectedComponent } from "./captchaProtected.component";
@Directive()
export class RegisterComponent extends CaptchaProtectedComponent implements OnInit {
name = "";
email = "";
masterPassword = "";
confirmMasterPassword = "";
hint = "";
@Input() isInTrialFlow = false;
@Output() createdAccount = new EventEmitter<string>();
showPassword = false;
formPromise: Promise<any>;
masterPasswordScore: number;
referenceData: ReferenceEventRequest;
showTerms = true;
acceptPolicies = false;
showErrorSummary = false;
formGroup = this.formBuilder.group(
{
email: ["", [Validators.required, Validators.email]],
name: [""],
masterPassword: ["", [Validators.required, Validators.minLength(8)]],
confirmMasterPassword: ["", [Validators.required, Validators.minLength(8)]],
hint: [
null,
[
InputsFieldMatch.validateInputsDoesntMatch(
"masterPassword",
this.i18nService.t("hintEqualsPassword")
),
],
],
acceptPolicies: [false, [this.acceptPoliciesValidation()]],
},
{
validator: InputsFieldMatch.validateFormInputsMatch(
"masterPassword",
"confirmMasterPassword",
this.i18nService.t("masterPassDoesntMatch")
),
}
);
protected successRoute = "login";
private masterPasswordStrengthTimeout: any;
constructor(
protected formValidationErrorService: FormValidationErrorsService,
protected formBuilder: FormBuilder,
protected authService: AuthService,
protected router: Router,
i18nService: I18nService,
@@ -84,59 +117,38 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
}
}
async submit() {
if (!this.acceptPolicies && this.showTerms) {
async submit(showToast = true) {
let email = this.formGroup.get("email")?.value;
let name = this.formGroup.get("name")?.value;
const masterPassword = this.formGroup.get("masterPassword")?.value;
const hint = this.formGroup.get("hint")?.value;
this.formGroup.markAllAsTouched();
this.showErrorSummary = true;
if (this.formGroup.get("acceptPolicies").hasError("required")) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("acceptPoliciesError")
this.i18nService.t("acceptPoliciesRequired")
);
return;
}
if (this.email == null || this.email === "") {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("emailRequired")
);
//web
if (this.formGroup.invalid && !showToast) {
return;
}
if (this.email.indexOf("@") === -1) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("invalidEmail")
);
return;
}
if (this.masterPassword == null || this.masterPassword === "") {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("masterPassRequired")
);
return;
}
if (this.masterPassword.length < 8) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("masterPassLength")
);
return;
}
if (this.masterPassword !== this.confirmMasterPassword) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("masterPassDoesntMatch")
);
//desktop, browser
if (this.formGroup.invalid && showToast) {
const errorText = this.getErrorToastMessage();
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), errorText);
return;
}
const strengthResult = this.passwordGenerationService.passwordStrength(
this.masterPassword,
masterPassword,
this.getPasswordStrengthUserInput()
);
if (strengthResult != null && strengthResult.score < 3) {
@@ -152,33 +164,19 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
}
}
if (this.hint === this.masterPassword) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("hintEqualsPassword")
);
return;
}
this.name = this.name === "" ? null : this.name;
this.email = this.email.trim().toLowerCase();
name = name === "" ? null : name;
email = email.trim().toLowerCase();
const kdf = DEFAULT_KDF_TYPE;
const kdfIterations = DEFAULT_KDF_ITERATIONS;
const key = await this.cryptoService.makeKey(
this.masterPassword,
this.email,
kdf,
kdfIterations
);
const key = await this.cryptoService.makeKey(masterPassword, email, kdf, kdfIterations);
const encKey = await this.cryptoService.makeEncKey(key);
const hashedPassword = await this.cryptoService.hashPassword(this.masterPassword, key);
const hashedPassword = await this.cryptoService.hashPassword(masterPassword, key);
const keys = await this.cryptoService.makeKeyPair(encKey[0]);
const request = new RegisterRequest(
this.email,
this.name,
email,
name,
hashedPassword,
this.hint,
hint,
encKey[1].encryptedString,
kdf,
kdfIterations,
@@ -203,25 +201,49 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
throw e;
}
}
this.platformUtilsService.showToast("success", null, this.i18nService.t("newAccountCreated"));
this.router.navigate([this.successRoute], { queryParams: { email: this.email } });
if (this.isInTrialFlow) {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("trialAccountCreated")
);
//login user here
const credentials = new PasswordLogInCredentials(
email,
masterPassword,
this.captchaToken,
null
);
await this.authService.logIn(credentials);
this.createdAccount.emit(this.formGroup.get("email")?.value);
} else {
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("newAccountCreated")
);
this.router.navigate([this.successRoute], { queryParams: { email: email } });
}
} catch (e) {
this.logService.error(e);
}
}
togglePassword(confirmField: boolean) {
togglePassword() {
this.showPassword = !this.showPassword;
document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus();
}
updatePasswordStrength() {
const masterPassword = this.formGroup.get("masterPassword")?.value;
if (this.masterPasswordStrengthTimeout != null) {
clearTimeout(this.masterPasswordStrengthTimeout);
}
this.masterPasswordStrengthTimeout = setTimeout(() => {
const strengthResult = this.passwordGenerationService.passwordStrength(
this.masterPassword,
masterPassword,
this.getPasswordStrengthUserInput()
);
this.masterPasswordScore = strengthResult == null ? null : strengthResult.score;
@@ -230,19 +252,56 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
private getPasswordStrengthUserInput() {
let userInput: string[] = [];
const atPosition = this.email.indexOf("@");
const email = this.formGroup.get("email")?.value;
const name = this.formGroup.get("name").value;
const atPosition = email.indexOf("@");
if (atPosition > -1) {
userInput = userInput.concat(
this.email
email
.substr(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
);
}
if (this.name != null && this.name !== "") {
userInput = userInput.concat(this.name.trim().toLowerCase().split(" "));
if (name != null && name !== "") {
userInput = userInput.concat(name.trim().toLowerCase().split(" "));
}
return userInput;
}
private getErrorToastMessage() {
const error: AllValidationErrors = this.formValidationErrorService
.getFormValidationErrors(this.formGroup.controls)
.shift();
if (error) {
switch (error.errorName) {
case "email":
return this.i18nService.t("invalidEmail");
case "inputsDoesntMatchError":
return this.i18nService.t("masterPassDoesntMatch");
case "inputsMatchError":
return this.i18nService.t("hintEqualsPassword");
default:
return this.i18nService.t(this.errorTag(error));
}
}
return;
}
private errorTag(error: AllValidationErrors): string {
const name = error.errorName.charAt(0).toUpperCase() + error.errorName.slice(1);
return `${error.controlName}${name}`;
}
//validation would be ignored on selfhosted
private acceptPoliciesValidation(): ValidatorFn {
return (control: AbstractControl) => {
const ctrlValue = control.value;
return !ctrlValue && this.showTerms ? { required: true } : null;
};
}
}

View File

@@ -128,6 +128,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterPasswordHash;
resetRequest.resetPasswordKey = encryptedKey.encryptedString;
return this.apiService.putOrganizationUserResetPasswordEnrollment(

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

View File

@@ -41,6 +41,7 @@ export class CollectionFilterComponent {
applyFilter(collection: CollectionView) {
this.activeFilter.resetFilter();
this.activeFilter.selectedCollection = true;
this.activeFilter.selectedCollectionId = collection.id;
this.onFilterChange.emit(this.activeFilter);
}

View File

@@ -0,0 +1,237 @@
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { CipherView } from "@bitwarden/common/models/view/cipherView";
import { VaultFilter } from "./vault-filter.model";
describe("VaultFilter", () => {
describe("filterFunction", () => {
describe("generic cipher", () => {
it("should return true when not filtering for anything specific", () => {
const cipher = createCipher();
const filterFunction = createFilterFunction({ status: "all" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
describe("given a favorite cipher", () => {
const cipher = createCipher({ favorite: true });
it("should return true when filtering for favorites", () => {
const filterFunction = createFilterFunction({ status: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filtering for trash", () => {
const filterFunction = createFilterFunction({ status: "trash" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a deleted cipher", () => {
const cipher = createCipher({ deletedDate: new Date() });
it("should return true when filtering for trash", () => {
const filterFunction = createFilterFunction({ status: "trash" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filtering for favorites", () => {
const filterFunction = createFilterFunction({ status: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a cipher with type", () => {
it("should return true when filter matches cipher type", () => {
const cipher = createCipher({ type: CipherType.Identity });
const filterFunction = createFilterFunction({ cipherType: CipherType.Identity });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filter does not match cipher type", () => {
const cipher = createCipher({ type: CipherType.Card });
const filterFunction = createFilterFunction({ cipherType: CipherType.Identity });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a cipher with folder id", () => {
it("should return true when filter matches folder id", () => {
const cipher = createCipher({ folderId: "folderId" });
const filterFunction = createFilterFunction({
selectedFolder: true,
selectedFolderId: "folderId",
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filter does not match folder id", () => {
const cipher = createCipher({ folderId: "folderId" });
const filterFunction = createFilterFunction({
selectedFolder: true,
selectedFolderId: "anotherFolderId",
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given a cipher without folder", () => {
const cipher = createCipher({ folderId: null });
it("should return true when filtering on unassigned folder", () => {
const filterFunction = createFilterFunction({
selectedFolder: true,
selectedFolderId: null,
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
describe("given an organizational cipher (with organization and collections)", () => {
const cipher = createCipher({
organizationId: "organizationId",
collectionIds: ["collectionId", "anotherId"],
});
it("should return true when filter matches collection id", () => {
const filterFunction = createFilterFunction({
selectedCollection: true,
selectedCollectionId: "collectionId",
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filter does not match collection id", () => {
const filterFunction = createFilterFunction({
selectedCollection: true,
selectedCollectionId: "nonMatchingId",
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filter does not match organization id", () => {
const filterFunction = createFilterFunction({
selectedOrganizationId: "anotherOrganizationId",
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filtering for my vault only", () => {
const filterFunction = createFilterFunction({
myVaultOnly: true,
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
});
describe("given an unassigned organizational cipher (with organization, without collection)", () => {
const cipher = createCipher({ organizationId: "organizationId", collectionIds: [] });
it("should return true when filtering for unassigned collection", () => {
const filterFunction = createFilterFunction({
selectedCollection: true,
selectedCollectionId: null,
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return true when filter matches organization id", () => {
const filterFunction = createFilterFunction({
selectedOrganizationId: "organizationId",
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
describe("given an individual cipher (without organization or collection)", () => {
const cipher = createCipher({ organizationId: null, collectionIds: [] });
it("should return false when filtering for unassigned collection", () => {
const filterFunction = createFilterFunction({
selectedCollection: true,
selectedCollectionId: null,
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return true when filtering for my vault only", () => {
const cipher = createCipher({ organizationId: null });
const filterFunction = createFilterFunction({
myVaultOnly: true,
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
});
});
function createFilterFunction(options: Partial<VaultFilter> = {}) {
return new VaultFilter(options).buildFilter();
}
function createCipher(options: Partial<CipherView> = {}) {
const cipher = new CipherView();
cipher.favorite = options.favorite ?? false;
cipher.deletedDate = options.deletedDate;
cipher.type = options.type;
cipher.folderId = options.folderId;
cipher.collectionIds = options.collectionIds;
cipher.organizationId = options.organizationId;
return cipher;
}

View File

@@ -1,9 +1,13 @@
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { CipherView } from "@bitwarden/common/models/view/cipherView";
import { CipherStatus } from "./cipher-status.model";
export type VaultFilterFunction = (cipher: CipherView) => boolean;
export class VaultFilter {
cipherType?: CipherType;
selectedCollection = false; // This is needed because of how the "Unassigned" collection works. It has a null id.
selectedCollectionId?: string;
status?: CipherStatus;
selectedFolder = false; // This is needed because of how the "No Folder" folder works. It has a null id.
@@ -19,6 +23,7 @@ export class VaultFilter {
resetFilter() {
this.cipherType = null;
this.status = null;
this.selectedCollection = false;
this.selectedCollectionId = null;
this.selectedFolder = false;
this.selectedFolderId = null;
@@ -29,4 +34,41 @@ export class VaultFilter {
this.selectedOrganizationId = null;
this.resetFilter();
}
buildFilter(): VaultFilterFunction {
return (cipher) => {
let cipherPassesFilter = true;
if (this.status === "favorites" && cipherPassesFilter) {
cipherPassesFilter = cipher.favorite;
}
if (this.status === "trash" && cipherPassesFilter) {
cipherPassesFilter = cipher.isDeleted;
}
if (this.cipherType != null && cipherPassesFilter) {
cipherPassesFilter = cipher.type === this.cipherType;
}
if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) {
cipherPassesFilter = cipher.folderId == null;
}
if (this.selectedFolder && this.selectedFolderId != null && cipherPassesFilter) {
cipherPassesFilter = cipher.folderId === this.selectedFolderId;
}
if (this.selectedCollection && this.selectedCollectionId == null && cipherPassesFilter) {
cipherPassesFilter =
cipher.organizationId != null &&
(cipher.collectionIds == null || cipher.collectionIds.length === 0);
}
if (this.selectedCollection && this.selectedCollectionId != null && cipherPassesFilter) {
cipherPassesFilter =
cipher.collectionIds != null && cipher.collectionIds.includes(this.selectedCollectionId);
}
if (this.selectedOrganizationId != null && cipherPassesFilter) {
cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId;
}
if (this.myVaultOnly && cipherPassesFilter) {
cipherPassesFilter = cipher.organizationId === null;
}
return cipherPassesFilter;
};
}
}

View File

@@ -1,4 +1,5 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { ITreeNodeObject } from "@bitwarden/common/models/domain/treeNode";
@@ -28,7 +29,7 @@ export class VaultFilterComponent implements OnInit {
activePersonalOwnershipPolicy: boolean;
activeSingleOrganizationPolicy: boolean;
collections: DynamicTreeNode<CollectionView>;
folders: DynamicTreeNode<FolderView>;
folders$: Observable<DynamicTreeNode<FolderView>>;
constructor(protected vaultFilterService: VaultFilterService) {}
@@ -45,7 +46,7 @@ export class VaultFilterComponent implements OnInit {
this.activeSingleOrganizationPolicy =
await this.vaultFilterService.checkForSingleOrganizationPolicy();
}
this.folders = await this.vaultFilterService.buildFolders();
this.folders$ = await this.vaultFilterService.buildNestedFolders();
this.collections = await this.initCollections();
this.isLoaded = true;
}
@@ -67,13 +68,13 @@ export class VaultFilterComponent implements OnInit {
async applyFilter(filter: VaultFilter) {
if (filter.refreshCollectionsAndFolders) {
await this.reloadCollectionsAndFolders(filter);
filter = this.pruneInvalidatedFilterSelections(filter);
filter = await this.pruneInvalidatedFilterSelections(filter);
}
this.onFilterChange.emit(filter);
}
async reloadCollectionsAndFolders(filter: VaultFilter) {
this.folders = await this.vaultFilterService.buildFolders(filter.selectedOrganizationId);
this.folders$ = await this.vaultFilterService.buildNestedFolders(filter.selectedOrganizationId);
this.collections = filter.myVaultOnly
? null
: await this.vaultFilterService.buildCollections(filter.selectedOrganizationId);
@@ -95,14 +96,17 @@ export class VaultFilterComponent implements OnInit {
this.onEditFolder.emit(folder);
}
protected pruneInvalidatedFilterSelections(filter: VaultFilter): VaultFilter {
filter = this.pruneInvalidFolderSelection(filter);
protected async pruneInvalidatedFilterSelections(filter: VaultFilter): Promise<VaultFilter> {
filter = await this.pruneInvalidFolderSelection(filter);
filter = this.pruneInvalidCollectionSelection(filter);
return filter;
}
protected pruneInvalidFolderSelection(filter: VaultFilter): VaultFilter {
if (filter.selectedFolder && !this.folders?.hasId(filter.selectedFolderId)) {
protected async pruneInvalidFolderSelection(filter: VaultFilter): Promise<VaultFilter> {
if (
filter.selectedFolder &&
!(await firstValueFrom(this.folders$))?.hasId(filter.selectedFolderId)
) {
filter.selectedFolder = false;
filter.selectedFolderId = null;
}
@@ -111,9 +115,12 @@ export class VaultFilterComponent implements OnInit {
protected pruneInvalidCollectionSelection(filter: VaultFilter): VaultFilter {
if (
filter.selectedCollectionId != null &&
!this.collections?.hasId(filter.selectedCollectionId)
filter.myVaultOnly ||
(filter.selectedCollection &&
filter.selectedCollectionId != null &&
!this.collections?.hasId(filter.selectedCollectionId))
) {
filter.selectedCollection = false;
filter.selectedCollectionId = null;
}
return filter;

View File

@@ -1,18 +1,23 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, from, mergeMap, Observable } from "rxjs";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/abstractions/folder.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { PolicyType } from "@bitwarden/common/enums/policyType";
import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { TreeNode } from "@bitwarden/common/models/domain/treeNode";
import { CollectionView } from "@bitwarden/common/models/view/collectionView";
import { FolderView } from "@bitwarden/common/models/view/folderView";
import { DynamicTreeNode } from "./models/dynamic-tree-node.model";
const NestingDelimiter = "/";
@Injectable()
export class VaultFilterService {
constructor(
@@ -36,25 +41,30 @@ export class VaultFilterService {
return await this.organizationService.getAll();
}
async buildFolders(organizationId?: string): Promise<DynamicTreeNode<FolderView>> {
const storedFolders = await this.folderService.getAllDecrypted();
let folders: FolderView[];
if (organizationId != null) {
const ciphers = await this.cipherService.getAllDecrypted();
const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId);
folders = storedFolders.filter(
(f) =>
orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 ||
ciphers.filter((c) => c.folderId == f.id).length < 1
);
} else {
folders = storedFolders;
}
const nestedFolders = await this.folderService.getAllNested(folders);
return new DynamicTreeNode<FolderView>({
fullList: folders,
nestedList: nestedFolders,
});
buildNestedFolders(organizationId?: string): Observable<DynamicTreeNode<FolderView>> {
const transformation = async (storedFolders: FolderView[]) => {
let folders: FolderView[];
if (organizationId != null) {
const ciphers = await this.cipherService.getAllDecrypted();
const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId);
folders = storedFolders.filter(
(f) =>
orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 ||
ciphers.filter((c) => c.folderId == f.id).length < 1
);
} else {
folders = storedFolders;
}
const nestedFolders = await this.getAllFoldersNested(folders);
return new DynamicTreeNode<FolderView>({
fullList: folders,
nestedList: nestedFolders,
});
};
return this.folderService.folderViews$.pipe(
mergeMap((folders) => from(transformation(folders)))
);
}
async buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>> {
@@ -79,4 +89,23 @@ export class VaultFilterService {
async checkForPersonalOwnershipPolicy(): Promise<boolean> {
return await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership);
}
protected async getAllFoldersNested(folders: FolderView[]): Promise<TreeNode<FolderView>[]> {
const nodes: TreeNode<FolderView>[] = [];
folders.forEach((f) => {
const folderCopy = new FolderView();
folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate;
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter);
});
return nodes;
}
async getFolderNested(id: string): Promise<TreeNode<FolderView>> {
const folders = await this.getAllFoldersNested(
await firstValueFrom(this.folderService.folderViews$)
);
return ServiceUtils.getTreeNodeObject(folders, id) as TreeNode<FolderView>;
}
}

View File

@@ -25,6 +25,12 @@ const numberFormats: Record<string, CardRuleEntry[]> = {
{ cardLength: 19, blocks: [6, 13] },
],
Amex: [{ cardLength: 15, blocks: [4, 6, 5] }],
Mir: [
{ cardLength: 16, blocks: [4, 4, 4, 4] },
{ cardLength: 17, blocks: [5, 4, 4, 4] },
{ cardLength: 18, blocks: [6, 4, 4, 4] },
{ cardLength: 19, blocks: [7, 4, 4, 4] },
],
Other: [{ cardLength: 16, blocks: [4, 4, 4, 4] }],
};

View File

@@ -9,6 +9,7 @@ $card-icons: (
"mastercard": $card-icons-base + "mastercard-light.png",
"union-pay": $card-icons-base + "union_pay-light.png",
"ru-pay": $card-icons-base + "ru_pay-light.png",
"mir": $card-icons-base + "mir-light.png",
);
$card-icons-dark: (
@@ -21,6 +22,7 @@ $card-icons-dark: (
"mastercard": $card-icons-base + "mastercard-dark.png",
"union-pay": $card-icons-base + "union_pay-dark.png",
"ru-pay": $card-icons-base + "ru_pay-dark.png",
"mir": $card-icons-base + "mir-dark.png",
);
.credit-card-icon {

View File

@@ -16,7 +16,12 @@ import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/
import { EventService as EventServiceAbstraction } from "@bitwarden/common/abstractions/event.service";
import { ExportService as ExportServiceAbstraction } from "@bitwarden/common/abstractions/export.service";
import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/abstractions/fileUpload.service";
import { FolderService as FolderServiceAbstraction } from "@bitwarden/common/abstractions/folder.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/abstractions/folder/folder-api.service.abstraction";
import {
FolderService as FolderServiceAbstraction,
InternalFolderService,
} from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "@bitwarden/common/abstractions/formValidationErrors.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/abstractions/keyConnector.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
@@ -57,7 +62,9 @@ import { EnvironmentService } from "@bitwarden/common/services/environment.servi
import { EventService } from "@bitwarden/common/services/event.service";
import { ExportService } from "@bitwarden/common/services/export.service";
import { FileUploadService } from "@bitwarden/common/services/fileUpload.service";
import { FolderService } from "@bitwarden/common/services/folder.service";
import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/services/folder/folder.service";
import { FormValidationErrorsService } from "@bitwarden/common/services/formValidationErrors.service";
import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
import { OrganizationService } from "@bitwarden/common/services/organization.service";
@@ -213,12 +220,20 @@ export const LOG_MAC_FAILURES = new InjectionToken<string>("LOG_MAC_FAILURES");
useClass: FolderService,
deps: [
CryptoServiceAbstraction,
ApiServiceAbstraction,
I18nServiceAbstraction,
CipherServiceAbstraction,
StateServiceAbstraction,
],
},
{
provide: InternalFolderService,
useExisting: FolderServiceAbstraction,
},
{
provide: FolderApiServiceAbstraction,
useClass: FolderApiService,
deps: [FolderServiceAbstraction, ApiServiceAbstraction],
},
{ provide: LogService, useFactory: () => new ConsoleLogService(false) },
{
provide: CollectionServiceAbstraction,
@@ -291,6 +306,7 @@ export const LOG_MAC_FAILURES = new InjectionToken<string>("LOG_MAC_FAILURES");
StateServiceAbstraction,
OrganizationServiceAbstraction,
ProviderServiceAbstraction,
FolderApiServiceAbstraction,
LOGOUT_CALLBACK,
],
},
@@ -444,6 +460,10 @@ export const LOG_MAC_FAILURES = new InjectionToken<string>("LOG_MAC_FAILURES");
provide: AbstractThemingService,
useClass: ThemingService,
},
{
provide: FormValidationErrorsServiceAbstraction,
useClass: FormValidationErrorsService,
},
],
})
export class JslibServicesModule {}

View File

@@ -1,4 +1,3 @@
import { MediaMatcher } from "@angular/cdk/layout";
import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core";
import { BehaviorSubject, filter, fromEvent, Observable } from "rxjs";
@@ -6,6 +5,8 @@ import { BehaviorSubject, filter, fromEvent, Observable } from "rxjs";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ThemeType } from "@bitwarden/common/enums/themeType";
import { WINDOW } from "../jslib-services.module";
import { Theme } from "./theme";
import { ThemeBuilder } from "./themeBuilder";
import { AbstractThemingService } from "./theming.service.abstraction";
@@ -17,7 +18,7 @@ export class ThemingService implements AbstractThemingService {
constructor(
private stateService: StateService,
private mediaMatcher: MediaMatcher,
@Inject(WINDOW) private window: Window,
@Inject(DOCUMENT) private document: Document
) {
this.monitorThemeChanges();
@@ -55,14 +56,14 @@ export class ThemingService implements AbstractThemingService {
// We use a media match query for monitoring the system theme on web and browser, but this doesn't work for electron apps on Linux.
// In desktop we override these methods to track systemTheme with the electron renderer instead, which works for all OSs.
protected async getSystemTheme(): Promise<ThemeType> {
return this.mediaMatcher.matchMedia("(prefers-color-scheme: dark)").matches
return this.window.matchMedia("(prefers-color-scheme: dark)").matches
? ThemeType.Dark
: ThemeType.Light;
}
protected monitorSystemThemeChanges(): void {
fromEvent<MediaQueryListEvent>(
this.mediaMatcher.matchMedia("(prefers-color-scheme: dark)"),
this.window.matchMedia("(prefers-color-scheme: dark)"),
"change"
).subscribe((event) => {
this.updateSystemTheme(event.matches ? ThemeType.Dark : ThemeType.Light);

View File

@@ -0,0 +1,57 @@
import { AbstractControl, FormGroup, ValidatorFn } from "@angular/forms";
import { FormGroupControls } from "@bitwarden/common/abstractions/formValidationErrors.service";
export class InputsFieldMatch {
//check to ensure two fields do not have the same value
static validateInputsDoesntMatch(matchTo: string, errorMessage: string): ValidatorFn {
return (control: AbstractControl) => {
if (control.parent && control.parent.controls) {
return control?.value === (control?.parent?.controls as FormGroupControls)[matchTo].value
? {
inputsMatchError: {
message: errorMessage,
},
}
: null;
}
return null;
};
}
//check to ensure two fields have the same value
static validateInputsMatch(matchTo: string, errorMessage: string): ValidatorFn {
return (control: AbstractControl) => {
if (control.parent && control.parent.controls) {
return control?.value === (control?.parent?.controls as FormGroupControls)[matchTo].value
? null
: {
inputsDoesntMatchError: {
message: errorMessage,
},
};
}
return null;
};
}
//checks the formGroup if two fields have the same value and validation is controlled from either field
static validateFormInputsMatch(field: string, fieldMatchTo: string, errorMessage: string) {
return (formGroup: FormGroup) => {
const fieldCtrl = formGroup.controls[field];
const fieldMatchToCtrl = formGroup.controls[fieldMatchTo];
if (fieldCtrl.value !== fieldMatchToCtrl.value) {
fieldMatchToCtrl.setErrors({
inputsDoesntMatchError: {
message: errorMessage,
},
});
} else {
fieldMatchToCtrl.setErrors(null);
}
};
}
}

View File

@@ -1,10 +1,11 @@
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service";
import { FolderService } from "@bitwarden/common/abstractions/folder.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { KdfType } from "@bitwarden/common/enums/kdfType";
import { Utils } from "@bitwarden/common/misc/utils";
@@ -97,8 +98,8 @@ describe("ExportService", () => {
folderService = Substitute.for<FolderService>();
cryptoService = Substitute.for<CryptoService>();
folderService.getAllDecrypted().resolves([]);
folderService.getAll().resolves([]);
folderService.folderViews$.returns(new BehaviorSubject([]));
folderService.folders$.returns(new BehaviorSubject([]));
exportService = new ExportService(
folderService,

View File

@@ -0,0 +1,195 @@
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.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";
import { FolderData } from "@bitwarden/common/models/data/folderData";
import { EncString } from "@bitwarden/common/models/domain/encString";
import { FolderView } from "@bitwarden/common/models/view/folderView";
import { ContainerService } from "@bitwarden/common/services/container.service";
import { FolderService } from "@bitwarden/common/services/folder/folder.service";
import { StateService } from "@bitwarden/common/services/state.service";
describe("Folder Service", () => {
let folderService: FolderService;
let cryptoService: SubstituteOf<CryptoService>;
let i18nService: SubstituteOf<I18nService>;
let cipherService: SubstituteOf<CipherService>;
let stateService: SubstituteOf<StateService>;
let broadcasterService: SubstituteOf<BroadcasterService>;
let activeAccount: BehaviorSubject<string>;
beforeEach(() => {
cryptoService = Substitute.for();
i18nService = Substitute.for();
cipherService = Substitute.for();
stateService = Substitute.for();
broadcasterService = Substitute.for();
activeAccount = new BehaviorSubject("123");
stateService.getEncryptedFolders().resolves({
"1": folderData("1", "test"),
});
stateService.activeAccount.returns(activeAccount);
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
folderService = new FolderService(
cryptoService,
i18nService,
cipherService,
stateService,
broadcasterService
);
});
it("encrypt", async () => {
const model = new FolderView();
model.id = "2";
model.name = "Test Folder";
cryptoService.encrypt(Arg.any()).resolves(new EncString("ENC"));
cryptoService.decryptToUtf8(Arg.any()).resolves("DEC");
const result = await folderService.encrypt(model);
expect(result).toEqual({
id: "2",
name: {
encryptedString: "ENC",
encryptionType: 0,
},
});
});
describe("get", () => {
it("exists", async () => {
const result = await folderService.get("1");
expect(result).toEqual({
id: "1",
name: {
decryptedValue: [],
encryptedString: "test",
encryptionType: 0,
},
revisionDate: null,
});
});
it("not exists", async () => {
const result = await folderService.get("2");
expect(result).toBe(undefined);
});
});
it("upsert", async () => {
await folderService.upsert(folderData("2", "test 2"));
expect(await firstValueFrom(folderService.folders$)).toEqual([
{
id: "1",
name: {
decryptedValue: [],
encryptedString: "test",
encryptionType: 0,
},
revisionDate: null,
},
{
id: "2",
name: {
decryptedValue: [],
encryptedString: "test 2",
encryptionType: 0,
},
revisionDate: null,
},
]);
expect(await firstValueFrom(folderService.folderViews$)).toEqual([
{ id: "1", name: [], revisionDate: null },
{ id: "2", name: [], revisionDate: null },
{ id: null, name: [], revisionDate: null },
]);
});
it("replace", async () => {
await folderService.replace({ "2": folderData("2", "test 2") });
expect(await firstValueFrom(folderService.folders$)).toEqual([
{
id: "2",
name: {
decryptedValue: [],
encryptedString: "test 2",
encryptionType: 0,
},
revisionDate: null,
},
]);
expect(await firstValueFrom(folderService.folderViews$)).toEqual([
{ id: "2", name: [], revisionDate: null },
{ id: null, name: [], revisionDate: null },
]);
});
it("delete", async () => {
await folderService.delete("1");
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect(await firstValueFrom(folderService.folderViews$)).toEqual([
{ id: null, name: [], revisionDate: null },
]);
});
it("clearCache", async () => {
await folderService.clearCache();
expect((await firstValueFrom(folderService.folders$)).length).toBe(1);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
describe("clear", () => {
it("null userId", async () => {
await folderService.clear();
stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any());
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
it("matching userId", async () => {
stateService.getUserId().resolves("1");
await folderService.clear("1");
stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any());
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
it("missmatching userId", async () => {
await folderService.clear("12");
stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any());
expect((await firstValueFrom(folderService.folders$)).length).toBe(1);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(2);
});
});
function folderData(id: string, name: string) {
const data = new FolderData({} as any);
data.id = id;
data.name = name;
return data;
}
});

View File

@@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { FolderService } from "@bitwarden/common/abstractions/folder.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { BitwardenPasswordProtectedImporter } from "@bitwarden/common/importers/bitwardenPasswordProtectedImporter";

View File

@@ -9,6 +9,9 @@ import { StateMigrationService } from "@bitwarden/common/services/stateMigration
const userId = "USER_ID";
// Note: each test calls the private migration method for that migration,
// so that we don't accidentally run all following migrations as well
describe("State Migration Service", () => {
let storageService: SubstituteOf<AbstractStorageService>;
let secureStorageService: SubstituteOf<AbstractStorageService>;
@@ -66,13 +69,13 @@ describe("State Migration Service", () => {
storageService.get(userId, Arg.any()).resolves(accountVersion3);
await stateMigrationService.migrate();
await (stateMigrationService as any).migrateStateFrom3To4();
storageService.received(1).save(userId, expectedAccountVersion4, Arg.any());
});
it("updates StateVersion number", async () => {
await stateMigrationService.migrate();
await (stateMigrationService as any).migrateStateFrom3To4();
storageService.received(1).save(
"global",
@@ -81,4 +84,47 @@ describe("State Migration Service", () => {
);
});
});
describe("StateVersion 4 to 5 migration", () => {
it("migrates organization keys to new format", async () => {
const accountVersion4 = new Account({
keys: {
organizationKeys: {
encrypted: {
orgOneId: "orgOneEncKey",
orgTwoId: "orgTwoEncKey",
orgThreeId: "orgThreeEncKey",
},
},
},
} as any);
const expectedAccount = new Account({
keys: {
organizationKeys: {
encrypted: {
orgOneId: {
type: "organization",
key: "orgOneEncKey",
},
orgTwoId: {
type: "organization",
key: "orgTwoEncKey",
},
orgThreeId: {
type: "organization",
key: "orgThreeEncKey",
},
},
},
},
});
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5(
accountVersion4
);
expect(migratedAccount).toEqual(expectedAccount);
});
});
});

View File

@@ -1,3 +1,4 @@
import { OrganizationApiKeyType } from "../enums/organizationApiKeyType";
import { OrganizationConnectionType } from "../enums/organizationConnectionType";
import { PolicyType } from "../enums/policyType";
import { SetKeyConnectorKeyRequest } from "../models/request/account/setKeyConnectorKeyRequest";
@@ -23,7 +24,6 @@ import { EmergencyAccessInviteRequest } from "../models/request/emergencyAccessI
import { EmergencyAccessPasswordRequest } from "../models/request/emergencyAccessPasswordRequest";
import { EmergencyAccessUpdateRequest } from "../models/request/emergencyAccessUpdateRequest";
import { EventRequest } from "../models/request/eventRequest";
import { FolderRequest } from "../models/request/folderRequest";
import { GroupRequest } from "../models/request/groupRequest";
import { IapCheckRequest } from "../models/request/iapCheckRequest";
import { ApiTokenRequest } from "../models/request/identityToken/apiTokenRequest";
@@ -117,7 +117,6 @@ import {
EmergencyAccessViewResponse,
} from "../models/response/emergencyAccessResponse";
import { EventResponse } from "../models/response/eventResponse";
import { FolderResponse } from "../models/response/folderResponse";
import { GroupDetailsResponse, GroupResponse } from "../models/response/groupResponse";
import { IdentityCaptchaResponse } from "../models/response/identityCaptchaResponse";
import { IdentityTokenResponse } from "../models/response/identityTokenResponse";
@@ -182,6 +181,16 @@ import { UserKeyResponse } from "../models/response/userKeyResponse";
import { SendAccessView } from "../models/view/sendAccessView";
export abstract class ApiService {
send: (
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body: any,
authed: boolean,
hasResponse: boolean,
apiUrl?: string,
alterHeaders?: (headers: Headers) => void
) => Promise<any>;
postIdentityToken: (
request: PasswordTokenRequest | SsoTokenRequest | ApiTokenRequest
) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
@@ -228,11 +237,6 @@ export abstract class ApiService {
getUserBillingHistory: () => Promise<BillingHistoryResponse>;
getUserBillingPayment: () => Promise<BillingPaymentResponse>;
getFolder: (id: string) => Promise<FolderResponse>;
postFolder: (request: FolderRequest) => Promise<FolderResponse>;
putFolder: (id: string, request: FolderRequest) => Promise<FolderResponse>;
deleteFolder: (id: string) => Promise<any>;
getSend: (id: string) => Promise<SendResponse>;
postSendAccess: (
id: string,
@@ -259,6 +263,7 @@ export abstract class ApiService {
renewSendFileUploadUrl: (sendId: string, fileId: string) => Promise<SendFileUploadDataResponse>;
getCipher: (id: string) => Promise<CipherResponse>;
getFullCipherDetails: (id: string) => Promise<CipherResponse>;
getCipherAdmin: (id: string) => Promise<CipherResponse>;
getAttachmentData: (
cipherId: string,
@@ -569,7 +574,8 @@ export abstract class ApiService {
request: OrganizationApiKeyRequest
) => Promise<ApiKeyResponse>;
getOrganizationApiKeyInformation: (
id: string
id: string,
type?: OrganizationApiKeyType
) => Promise<ListResponse<OrganizationApiKeyInformationResponse>>;
postOrganizationRotateApiKey: (
id: string,

View File

@@ -9,6 +9,7 @@ export type Urls = {
notifications?: string;
events?: string;
keyConnector?: string;
scim?: string;
};
export type PayPalConfig = {
@@ -28,6 +29,7 @@ export abstract class EnvironmentService {
getIdentityUrl: () => string;
getEventsUrl: () => string;
getKeyConnectorUrl: () => string;
getScimUrl: () => string;
setUrlsFromStorage: () => Promise<void>;
setUrls: (urls: Urls) => Promise<Urls>;
getUrls: () => Urls;

View File

@@ -1,21 +0,0 @@
import { FolderData } from "../models/data/folderData";
import { Folder } from "../models/domain/folder";
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
import { TreeNode } from "../models/domain/treeNode";
import { FolderView } from "../models/view/folderView";
export abstract class FolderService {
clearCache: (userId?: string) => Promise<void>;
encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise<Folder>;
get: (id: string) => Promise<Folder>;
getAll: () => Promise<Folder[]>;
getAllDecrypted: () => Promise<FolderView[]>;
getAllNested: (folders?: FolderView[]) => Promise<TreeNode<FolderView>[]>;
getNested: (id: string) => Promise<TreeNode<FolderView>>;
saveWithServer: (folder: Folder) => Promise<any>;
upsert: (folder: FolderData | FolderData[]) => Promise<any>;
replace: (folders: { [id: string]: FolderData }) => Promise<any>;
clear: (userId: string) => Promise<any>;
delete: (id: string | string[]) => Promise<any>;
deleteWithServer: (id: string) => Promise<any>;
}

View File

@@ -0,0 +1,8 @@
import { Folder } from "@bitwarden/common/models/domain/folder";
import { FolderResponse } from "@bitwarden/common/models/response/folderResponse";
export class FolderApiServiceAbstraction {
save: (folder: Folder) => Promise<any>;
delete: (id: string) => Promise<any>;
get: (id: string) => Promise<FolderResponse>;
}

View File

@@ -0,0 +1,26 @@
import { Observable } from "rxjs";
import { FolderData } from "../../models/data/folderData";
import { Folder } from "../../models/domain/folder";
import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey";
import { FolderView } from "../../models/view/folderView";
export abstract class FolderService {
folders$: Observable<Folder[]>;
folderViews$: Observable<FolderView[]>;
clearCache: () => Promise<void>;
encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise<Folder>;
get: (id: string) => Promise<Folder>;
/**
* @deprecated Only use in CLI!
*/
getAllDecryptedFromState: () => Promise<FolderView[]>;
}
export abstract class InternalFolderService extends FolderService {
upsert: (folder: FolderData | FolderData[]) => Promise<void>;
replace: (folders: { [id: string]: FolderData }) => Promise<void>;
clear: (userId: string) => Promise<any>;
delete: (id: string | string[]) => Promise<any>;
}

View File

@@ -0,0 +1,13 @@
import { AbstractControl } from "@angular/forms";
export interface AllValidationErrors {
controlName: string;
errorName: string;
}
export interface FormGroupControls {
[key: string]: AbstractControl;
}
export abstract class FormValidationErrorsService {
getFormValidationErrors: (controls: FormGroupControls) => AllValidationErrors[];
}

View File

@@ -1,5 +1,7 @@
import { Observable } from "rxjs";
export abstract class I18nService {
locale: string;
locale$: Observable<string>;
supportedTranslationLocales: string[];
translationLocale: string;
collator: Intl.Collator;

View File

@@ -1,10 +1,11 @@
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, Observable } from "rxjs";
import { KdfType } from "../enums/kdfType";
import { ThemeType } from "../enums/themeType";
import { UriMatchType } from "../enums/uriMatchType";
import { CipherData } from "../models/data/cipherData";
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 { OrganizationData } from "../models/data/organizationData";
@@ -21,13 +22,14 @@ import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
import { WindowState } from "../models/domain/windowState";
import { CipherView } from "../models/view/cipherView";
import { CollectionView } from "../models/view/collectionView";
import { FolderView } from "../models/view/folderView";
import { SendView } from "../models/view/sendView";
export abstract class StateService<T extends Account = Account> {
accounts: BehaviorSubject<{ [userId: string]: T }>;
activeAccount: BehaviorSubject<string>;
activeAccountUnlocked: Observable<boolean>;
addAccount: (account: T) => Promise<void>;
setActiveUser: (userId: string) => Promise<void>;
clean: (options?: StorageOptions) => Promise<void>;
@@ -88,8 +90,6 @@ export abstract class StateService<T extends Account = Account> {
value: SymmetricCryptoKey,
options?: StorageOptions
) => Promise<void>;
getDecryptedFolders: (options?: StorageOptions) => Promise<FolderView[]>;
setDecryptedFolders: (value: FolderView[], options?: StorageOptions) => Promise<void>;
getDecryptedOrganizationKeys: (
options?: StorageOptions
) => Promise<Map<string, SymmetricCryptoKey>>;
@@ -183,14 +183,22 @@ export abstract class StateService<T extends Account = Account> {
) => Promise<void>;
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
setEncryptedCryptoSymmetricKey: (value: string, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use FolderService
*/
getEncryptedFolders: (options?: StorageOptions) => Promise<{ [id: string]: FolderData }>;
/**
* @deprecated Do not call this directly, use FolderService
*/
setEncryptedFolders: (
value: { [id: string]: FolderData },
options?: StorageOptions
) => Promise<void>;
getEncryptedOrganizationKeys: (options?: StorageOptions) => Promise<any>;
getEncryptedOrganizationKeys: (
options?: StorageOptions
) => Promise<{ [orgId: string]: EncryptedOrganizationKeyData }>;
setEncryptedOrganizationKeys: (
value: Map<string, SymmetricCryptoKey>,
value: { [orgId: string]: EncryptedOrganizationKeyData },
options?: StorageOptions
) => Promise<void>;
getEncryptedPasswordGenerationHistory: (

View File

@@ -48,6 +48,8 @@ export enum EventType {
OrganizationUser_AdminResetPassword = 1508,
OrganizationUser_ResetSsoLink = 1509,
OrganizationUser_FirstSsoLogin = 1510,
OrganizationUser_Deactivated = 1511,
OrganizationUser_Activated = 1512,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@@ -1,4 +1,5 @@
export enum OrganizationApiKeyType {
Default = 0,
BillingSync = 1,
Scim = 2,
}

View File

@@ -1,3 +1,4 @@
export enum OrganizationConnectionType {
CloudBillingSync = 1,
Scim = 2,
}

View File

@@ -25,4 +25,5 @@ export enum Permissions {
DeleteAssignedCollections,
ManageSso,
ManageBilling,
ManageScim,
}

View File

@@ -0,0 +1,9 @@
export enum ScimProviderType {
Default = 0,
AzureAd = 1,
Okta = 2,
OneLogin = 3,
JumpCloud = 4,
GoogleWorkspace = 5,
Rippling = 6,
}

View File

@@ -3,5 +3,6 @@ export enum StateVersion {
Two = 2, // Move to a typed State object
Three = 3, // Fix migration of users' premium status
Four = 4, // Fix 'Never Lock' option by removing stale data
Latest = Four,
Five = 5, // Migrate to new storage of encrypted organization keys
Latest = Five,
}

View File

@@ -301,6 +301,12 @@ export abstract class BaseImporter {
return "Visa";
}
// Mir
re = new RegExp("^220[0-4]");
if (cardNum.match(re) != null) {
return "Mir";
}
return null;
}

View File

@@ -25,6 +25,7 @@ export class PermissionsApi extends BaseResponse {
managePolicies: boolean;
manageUsers: boolean;
manageResetPassword: boolean;
manageScim: boolean;
constructor(data: any = null) {
super(data);
@@ -51,5 +52,6 @@ export class PermissionsApi extends BaseResponse {
this.managePolicies = this.getResponseProperty("ManagePolicies");
this.manageUsers = this.getResponseProperty("ManageUsers");
this.manageResetPassword = this.getResponseProperty("ManageResetPassword");
this.manageScim = this.getResponseProperty("ManageScim");
}
}

View File

@@ -0,0 +1,17 @@
import { ScimProviderType } from "@bitwarden/common/enums/scimProviderType";
import { BaseResponse } from "../response/baseResponse";
export class ScimConfigApi extends BaseResponse {
enabled: boolean;
scimProvider: ScimProviderType;
constructor(data: any) {
super(data);
if (data == null) {
return;
}
this.enabled = this.getResponseProperty("Enabled");
this.scimProvider = this.getResponseProperty("ScimProvider");
}
}

View File

@@ -0,0 +1,14 @@
export type EncryptedOrganizationKeyData =
| OrganizationEncryptedOrganizationKeyData
| ProviderEncryptedOrganizationKeyData;
type OrganizationEncryptedOrganizationKeyData = {
type: "organization";
key: string;
};
type ProviderEncryptedOrganizationKeyData = {
type: "provider";
key: string;
providerId: string;
};

View File

@@ -19,6 +19,7 @@ export class OrganizationData {
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
useScim: boolean;
useResetPassword: boolean;
selfHost: boolean;
usersGetPremium: boolean;
@@ -58,6 +59,7 @@ export class OrganizationData {
this.useApi = response.useApi;
this.useSso = response.useSso;
this.useKeyConnector = response.useKeyConnector;
this.useScim = response.useScim;
this.useResetPassword = response.useResetPassword;
this.selfHost = response.selfHost;
this.usersGetPremium = response.usersGetPremium;

View File

@@ -3,6 +3,7 @@ import { KdfType } from "../../enums/kdfType";
import { UriMatchType } from "../../enums/uriMatchType";
import { CipherData } from "../data/cipherData";
import { CollectionData } from "../data/collectionData";
import { EncryptedOrganizationKeyData } from "../data/encryptedOrganizationKeyData";
import { EventData } from "../data/eventData";
import { FolderData } from "../data/folderData";
import { OrganizationData } from "../data/organizationData";
@@ -11,7 +12,6 @@ import { ProviderData } from "../data/providerData";
import { SendData } from "../data/sendData";
import { CipherView } from "../view/cipherView";
import { CollectionView } from "../view/collectionView";
import { FolderView } from "../view/folderView";
import { SendView } from "../view/sendView";
import { EncString } from "./encString";
@@ -31,15 +31,19 @@ export class DataEncryptionPair<TEncrypted, TDecrypted> {
decrypted?: TDecrypted[];
}
// This is a temporary structure to handle migrated `DataEncryptionPair` to
// avoid needing a data migration at this stage. It should be replaced with
// proper data migrations when `DataEncryptionPair` is deprecated.
export class TemporaryDataEncryption<TEncrypted> {
encrypted?: { [id: string]: TEncrypted };
}
export class AccountData {
ciphers?: DataEncryptionPair<CipherData, CipherView> = new DataEncryptionPair<
CipherData,
CipherView
>();
folders?: DataEncryptionPair<FolderData, FolderView> = new DataEncryptionPair<
FolderData,
FolderView
>();
folders? = new TemporaryDataEncryption<FolderData>();
localData?: any;
sends?: DataEncryptionPair<SendData, SendView> = new DataEncryptionPair<SendData, SendView>();
collections?: DataEncryptionPair<CollectionData, CollectionView> = new DataEncryptionPair<
@@ -66,8 +70,11 @@ export class AccountKeys {
string,
SymmetricCryptoKey
>();
organizationKeys?: EncryptionPair<any, Map<string, SymmetricCryptoKey>> = new EncryptionPair<
any,
organizationKeys?: EncryptionPair<
{ [orgId: string]: EncryptedOrganizationKeyData },
Map<string, SymmetricCryptoKey>
> = new EncryptionPair<
{ [orgId: string]: EncryptedOrganizationKeyData },
Map<string, SymmetricCryptoKey>
>();
providerKeys?: EncryptionPair<any, Map<string, SymmetricCryptoKey>> = new EncryptionPair<

View File

@@ -0,0 +1,56 @@
import { CryptoService } from "../../abstractions/crypto.service";
import { EncryptedOrganizationKeyData } from "../../models/data/encryptedOrganizationKeyData";
import { EncString } from "./encString";
import { SymmetricCryptoKey } from "./symmetricCryptoKey";
export abstract class BaseEncryptedOrganizationKey {
decrypt: (cryptoService: CryptoService) => Promise<SymmetricCryptoKey>;
static fromData(data: EncryptedOrganizationKeyData) {
switch (data.type) {
case "organization":
return new EncryptedOrganizationKey(data.key);
case "provider":
return new ProviderEncryptedOrganizationKey(data.key, data.providerId);
default:
return null;
}
}
}
export class EncryptedOrganizationKey implements BaseEncryptedOrganizationKey {
constructor(private key: string) {}
async decrypt(cryptoService: CryptoService) {
const decValue = await cryptoService.rsaDecrypt(this.key);
return new SymmetricCryptoKey(decValue);
}
toData(): EncryptedOrganizationKeyData {
return {
type: "organization",
key: this.key,
};
}
}
export class ProviderEncryptedOrganizationKey implements BaseEncryptedOrganizationKey {
constructor(private key: string, private providerId: string) {}
async decrypt(cryptoService: CryptoService) {
const providerKey = await cryptoService.getProviderKey(this.providerId);
const decValue = await cryptoService.decryptToBytes(new EncString(this.key), providerKey);
return new SymmetricCryptoKey(decValue);
}
toData(): EncryptedOrganizationKeyData {
return {
type: "provider",
key: this.key,
providerId: this.providerId,
};
}
}

View File

@@ -20,6 +20,7 @@ export class Organization {
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
useScim: boolean;
useResetPassword: boolean;
selfHost: boolean;
usersGetPremium: boolean;
@@ -63,6 +64,7 @@ export class Organization {
this.useApi = obj.useApi;
this.useSso = obj.useSso;
this.useKeyConnector = obj.useKeyConnector;
this.useScim = obj.useScim;
this.useResetPassword = obj.useResetPassword;
this.selfHost = obj.selfHost;
this.usersGetPremium = obj.usersGetPremium;
@@ -173,6 +175,10 @@ export class Organization {
return this.isAdmin || this.permissions.manageSso;
}
get canManageScim() {
return this.isAdmin || this.permissions.manageScim;
}
get canManagePolicies() {
return this.isAdmin || this.permissions.managePolicies;
}
@@ -207,6 +213,7 @@ export class Organization {
(permissions.includes(Permissions.ManageUsers) && this.canManageUsers) ||
(permissions.includes(Permissions.ManageUsersPassword) && this.canManageUsersPassword) ||
(permissions.includes(Permissions.ManageSso) && this.canManageSso) ||
(permissions.includes(Permissions.ManageScim) && this.canManageScim) ||
(permissions.includes(Permissions.ManageBilling) && this.canManageBilling);
return specifiedPermissions && (this.enabled || this.isOwner);

View File

@@ -1,9 +1,10 @@
import { OrganizationConnectionType } from "../../enums/organizationConnectionType";
import { BillingSyncConfigRequest } from "./billingSyncConfigRequest";
import { ScimConfigRequest } from "./scimConfigRequest";
/**API request config types for OrganizationConnectionRequest */
export type OrganizationConnectionRequestConfigs = BillingSyncConfigRequest;
export type OrganizationConnectionRequestConfigs = BillingSyncConfigRequest | ScimConfigRequest;
export class OrganizationConnectionRequest {
constructor(

View File

@@ -2,5 +2,6 @@ import { SecretVerificationRequest } from "./secretVerificationRequest";
export class PasswordRequest extends SecretVerificationRequest {
newMasterPasswordHash: string;
masterPasswordHint: string;
key: string;
}

View File

@@ -0,0 +1,5 @@
import { ScimProviderType } from "@bitwarden/common/enums/scimProviderType";
export class ScimConfigRequest {
constructor(private enabled: boolean, private scimProvider: ScimProviderType = null) {}
}

View File

@@ -21,15 +21,13 @@ export class ErrorResponse extends BaseResponse {
}
}
if (errorModel) {
if (status === 429) {
this.message = "Rate limit exceeded. Try again later.";
} else if (errorModel) {
this.message = this.getResponseProperty("Message", errorModel);
this.validationErrors = this.getResponseProperty("ValidationErrors", errorModel);
this.captchaSiteKey = this.validationErrors?.HCaptcha_SiteKey?.[0];
this.captchaRequired = !Utils.isNullOrWhitespace(this.captchaSiteKey);
} else {
if (status === 429) {
this.message = "Rate limit exceeded. Try again later.";
}
}
this.statusCode = status;
}

View File

@@ -1,10 +1,11 @@
import { OrganizationConnectionType } from "../../enums/organizationConnectionType";
import { BillingSyncConfigApi } from "../api/billingSyncConfigApi";
import { ScimConfigApi } from "../api/scimConfigApi";
import { BaseResponse } from "./baseResponse";
/**API response config types for OrganizationConnectionResponse */
export type OrganizationConnectionConfigApis = BillingSyncConfigApi;
export type OrganizationConnectionConfigApis = BillingSyncConfigApi | ScimConfigApi;
export class OrganizationConnectionResponse<
TConfig extends OrganizationConnectionConfigApis

View File

@@ -17,6 +17,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
useScim: boolean;
useResetPassword: boolean;
selfHost: boolean;
usersGetPremium: boolean;
@@ -57,6 +58,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
this.useApi = this.getResponseProperty("UseApi");
this.useSso = this.getResponseProperty("UseSso");
this.useKeyConnector = this.getResponseProperty("UseKeyConnector") ?? false;
this.useScim = this.getResponseProperty("UseScim") ?? false;
this.useResetPassword = this.getResponseProperty("UseResetPassword");
this.selfHost = this.getResponseProperty("SelfHost");
this.usersGetPremium = this.getResponseProperty("UsersGetPremium");

View File

@@ -4,6 +4,7 @@ import { EnvironmentService } from "../abstractions/environment.service";
import { PlatformUtilsService } from "../abstractions/platformUtils.service";
import { TokenService } from "../abstractions/token.service";
import { DeviceType } from "../enums/deviceType";
import { OrganizationApiKeyType } from "../enums/organizationApiKeyType";
import { OrganizationConnectionType } from "../enums/organizationConnectionType";
import { PolicyType } from "../enums/policyType";
import { Utils } from "../misc/utils";
@@ -30,7 +31,6 @@ import { EmergencyAccessInviteRequest } from "../models/request/emergencyAccessI
import { EmergencyAccessPasswordRequest } from "../models/request/emergencyAccessPasswordRequest";
import { EmergencyAccessUpdateRequest } from "../models/request/emergencyAccessUpdateRequest";
import { EventRequest } from "../models/request/eventRequest";
import { FolderRequest } from "../models/request/folderRequest";
import { GroupRequest } from "../models/request/groupRequest";
import { IapCheckRequest } from "../models/request/iapCheckRequest";
import { ApiTokenRequest } from "../models/request/identityToken/apiTokenRequest";
@@ -126,7 +126,6 @@ import {
} from "../models/response/emergencyAccessResponse";
import { ErrorResponse } from "../models/response/errorResponse";
import { EventResponse } from "../models/response/eventResponse";
import { FolderResponse } from "../models/response/folderResponse";
import { GroupDetailsResponse, GroupResponse } from "../models/response/groupResponse";
import { IdentityCaptchaResponse } from "../models/response/identityCaptchaResponse";
import { IdentityTokenResponse } from "../models/response/identityTokenResponse";
@@ -487,27 +486,6 @@ export class ApiService implements ApiServiceAbstraction {
return new BillingPaymentResponse(r);
}
// Folder APIs
async getFolder(id: string): Promise<FolderResponse> {
const r = await this.send("GET", "/folders/" + id, null, true, true);
return new FolderResponse(r);
}
async postFolder(request: FolderRequest): Promise<FolderResponse> {
const r = await this.send("POST", "/folders", request, true, true);
return new FolderResponse(r);
}
async putFolder(id: string, request: FolderRequest): Promise<FolderResponse> {
const r = await this.send("PUT", "/folders/" + id, request, true, true);
return new FolderResponse(r);
}
deleteFolder(id: string): Promise<any> {
return this.send("DELETE", "/folders/" + id, null, true, false);
}
// Send APIs
async getSend(id: string): Promise<SendResponse> {
@@ -612,6 +590,11 @@ export class ApiService implements ApiServiceAbstraction {
return new CipherResponse(r);
}
async getFullCipherDetails(id: string): Promise<CipherResponse> {
const r = await this.send("GET", "/ciphers/" + id + "/details", null, true, true);
return new CipherResponse(r);
}
async getCipherAdmin(id: string): Promise<CipherResponse> {
const r = await this.send("GET", "/ciphers/" + id + "/admin", null, true, true);
return new CipherResponse(r);
@@ -1412,7 +1395,7 @@ export class ApiService implements ApiServiceAbstraction {
// Plan APIs
async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.send("GET", "/plans/", null, true, true);
const r = await this.send("GET", "/plans/", null, false, true);
return new ListResponse(r, PlanResponse);
}
@@ -1840,15 +1823,14 @@ export class ApiService implements ApiServiceAbstraction {
}
async getOrganizationApiKeyInformation(
id: string
id: string,
type: OrganizationApiKeyType = null
): Promise<ListResponse<OrganizationApiKeyInformationResponse>> {
const r = await this.send(
"GET",
"/organizations/" + id + "/api-key-information",
null,
true,
true
);
const uri =
type === null
? "/organizations/" + id + "/api-key-information"
: "/organizations/" + id + "/api-key-information/" + type;
const r = await this.send("GET", uri, null, true, true);
return new ListResponse(r, OrganizationApiKeyInformationResponse);
}
@@ -2566,7 +2548,7 @@ export class ApiService implements ApiServiceAbstraction {
await this.tokenService.setToken(response.accessToken);
}
private async send(
async send(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body: any,

View File

@@ -86,6 +86,7 @@ export class CollectionService implements CollectionServiceAbstraction {
const collections = await this.getAll();
decryptedCollections = await this.decryptMany(collections);
await this.stateService.setDecryptedCollections(decryptedCollections);
return decryptedCollections;
}

View File

@@ -13,9 +13,11 @@ import { KeySuffixOptions } from "../enums/keySuffixOptions";
import { sequentialize } from "../misc/sequentialize";
import { Utils } from "../misc/utils";
import { EEFLongWordList } from "../misc/wordlist";
import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData";
import { EncArrayBuffer } from "../models/domain/encArrayBuffer";
import { EncString } from "../models/domain/encString";
import { EncryptedObject } from "../models/domain/encryptedObject";
import { BaseEncryptedOrganizationKey } from "../models/domain/encryptedOrganizationKey";
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
import { ProfileOrganizationResponse } from "../models/response/profileOrganizationResponse";
import { ProfileProviderOrganizationResponse } from "../models/response/profileProviderOrganizationResponse";
@@ -58,23 +60,28 @@ export class CryptoService implements CryptoServiceAbstraction {
}
async setOrgKeys(
orgs: ProfileOrganizationResponse[],
providerOrgs: ProfileProviderOrganizationResponse[]
orgs: ProfileOrganizationResponse[] = [],
providerOrgs: ProfileProviderOrganizationResponse[] = []
): Promise<void> {
const orgKeys: any = {};
const encOrgKeyData: { [orgId: string]: EncryptedOrganizationKeyData } = {};
orgs.forEach((org) => {
orgKeys[org.id] = org.key;
encOrgKeyData[org.id] = {
type: "organization",
key: org.key,
};
});
for (const providerOrg of providerOrgs) {
// Convert provider encrypted keys to user encrypted.
const providerKey = await this.getProviderKey(providerOrg.providerId);
const decValue = await this.decryptToBytes(new EncString(providerOrg.key), providerKey);
orgKeys[providerOrg.id] = (await this.rsaEncrypt(decValue)).encryptedString;
}
providerOrgs.forEach((org) => {
encOrgKeyData[org.id] = {
type: "provider",
providerId: org.providerId,
key: org.key,
};
});
await this.stateService.setDecryptedOrganizationKeys(null);
return await this.stateService.setEncryptedOrganizationKeys(orgKeys);
return await this.stateService.setEncryptedOrganizationKeys(encOrgKeyData);
}
async setProviderKeys(providers: ProfileProviderResponse[]): Promise<void> {
@@ -211,35 +218,36 @@ export class CryptoService implements CryptoServiceAbstraction {
@sequentialize(() => "getOrgKeys")
async getOrgKeys(): Promise<Map<string, SymmetricCryptoKey>> {
const orgKeys: Map<string, SymmetricCryptoKey> = new Map<string, SymmetricCryptoKey>();
const result: Map<string, SymmetricCryptoKey> = new Map<string, SymmetricCryptoKey>();
const decryptedOrganizationKeys = await this.stateService.getDecryptedOrganizationKeys();
if (decryptedOrganizationKeys != null && decryptedOrganizationKeys.size > 0) {
return decryptedOrganizationKeys;
}
const encOrgKeys = await this.stateService.getEncryptedOrganizationKeys();
if (encOrgKeys == null) {
const encOrgKeyData = await this.stateService.getEncryptedOrganizationKeys();
if (encOrgKeyData == null) {
return null;
}
let setKey = false;
for (const orgId in encOrgKeys) {
// eslint-disable-next-line
if (!encOrgKeys.hasOwnProperty(orgId)) {
for (const orgId of Object.keys(encOrgKeyData)) {
if (result.has(orgId)) {
continue;
}
const decValue = await this.rsaDecrypt(encOrgKeys[orgId]);
orgKeys.set(orgId, new SymmetricCryptoKey(decValue));
const encOrgKey = BaseEncryptedOrganizationKey.fromData(encOrgKeyData[orgId]);
const decOrgKey = await encOrgKey.decrypt(this);
result.set(orgId, decOrgKey);
setKey = true;
}
if (setKey) {
await this.stateService.setDecryptedOrganizationKeys(orgKeys);
await this.stateService.setDecryptedOrganizationKeys(result);
}
return orgKeys;
return result;
}
async getOrgKey(orgId: string): Promise<SymmetricCryptoKey> {

View File

@@ -19,6 +19,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
private notificationsUrl: string;
private eventsUrl: string;
private keyConnectorUrl: string;
private scimUrl: string = null;
constructor(private stateService: StateService) {
this.stateService.activeAccount.subscribe(async () => {
@@ -111,6 +112,16 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
return this.keyConnectorUrl;
}
getScimUrl() {
if (this.scimUrl != null) {
return this.scimUrl + "/v2";
}
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
? "https://scim.bitwarden.com/v2"
: this.getWebVaultUrl() + "/scim/v2";
}
async setUrlsFromStorage(): Promise<void> {
const urls: any = await this.stateService.getEnvironmentUrls();
const envUrls = new EnvironmentUrls();
@@ -123,6 +134,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
this.notificationsUrl = urls.notifications;
this.eventsUrl = envUrls.events = urls.events;
this.keyConnectorUrl = urls.keyConnector;
// scimUrl is not saved to storage
}
async setUrls(urls: Urls): Promise<Urls> {
@@ -135,6 +147,9 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
urls.events = this.formatUrl(urls.events);
urls.keyConnector = this.formatUrl(urls.keyConnector);
// scimUrl cannot be cleared
urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl;
await this.stateService.setEnvironmentUrls({
base: urls.base,
api: urls.api,
@@ -144,6 +159,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
notifications: urls.notifications,
events: urls.events,
keyConnector: urls.keyConnector,
// scimUrl is not saved to storage
});
this.baseUrl = urls.base;
@@ -154,6 +170,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
this.notificationsUrl = urls.notifications;
this.eventsUrl = urls.events;
this.keyConnectorUrl = urls.keyConnector;
this.scimUrl = urls.scim;
this.urlsSubject.next(urls);
@@ -170,6 +187,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
notifications: this.notificationsUrl,
events: this.eventsUrl,
keyConnector: this.keyConnectorUrl,
scim: this.scimUrl,
};
}

View File

@@ -1,4 +1,5 @@
import * as papa from "papaparse";
import { firstValueFrom } from "rxjs";
import { ApiService } from "../abstractions/api.service";
import { CipherService } from "../abstractions/cipher.service";
@@ -8,7 +9,7 @@ import {
ExportFormat,
ExportService as ExportServiceAbstraction,
} from "../abstractions/export.service";
import { FolderService } from "../abstractions/folder.service";
import { FolderService } from "../abstractions/folder/folder.service.abstraction";
import { CipherType } from "../enums/cipherType";
import { DEFAULT_KDF_ITERATIONS, KdfType } from "../enums/kdfType";
import { Utils } from "../misc/utils";
@@ -115,7 +116,7 @@ export class ExportService implements ExportServiceAbstraction {
const promises = [];
promises.push(
this.folderService.getAllDecrypted().then((folders) => {
firstValueFrom(this.folderService.folderViews$).then((folders) => {
decFolders = folders;
})
);
@@ -191,7 +192,7 @@ export class ExportService implements ExportServiceAbstraction {
const promises = [];
promises.push(
this.folderService.getAll().then((f) => {
firstValueFrom(this.folderService.folders$).then((f) => {
folders = f;
})
);

View File

@@ -1,193 +0,0 @@
import { ApiService } from "../abstractions/api.service";
import { CipherService } from "../abstractions/cipher.service";
import { CryptoService } from "../abstractions/crypto.service";
import { FolderService as FolderServiceAbstraction } from "../abstractions/folder.service";
import { I18nService } from "../abstractions/i18n.service";
import { StateService } from "../abstractions/state.service";
import { ServiceUtils } from "../misc/serviceUtils";
import { Utils } from "../misc/utils";
import { CipherData } from "../models/data/cipherData";
import { FolderData } from "../models/data/folderData";
import { Folder } from "../models/domain/folder";
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
import { TreeNode } from "../models/domain/treeNode";
import { FolderRequest } from "../models/request/folderRequest";
import { FolderResponse } from "../models/response/folderResponse";
import { FolderView } from "../models/view/folderView";
const NestingDelimiter = "/";
export class FolderService implements FolderServiceAbstraction {
constructor(
private cryptoService: CryptoService,
private apiService: ApiService,
private i18nService: I18nService,
private cipherService: CipherService,
private stateService: StateService
) {}
async clearCache(userId?: string): Promise<void> {
await this.stateService.setDecryptedFolders(null, { userId: userId });
}
async encrypt(model: FolderView, key?: SymmetricCryptoKey): Promise<Folder> {
const folder = new Folder();
folder.id = model.id;
folder.name = await this.cryptoService.encrypt(model.name, key);
return folder;
}
async get(id: string): Promise<Folder> {
const folders = await this.stateService.getEncryptedFolders();
// eslint-disable-next-line
if (folders == null || !folders.hasOwnProperty(id)) {
return null;
}
return new Folder(folders[id]);
}
async getAll(): Promise<Folder[]> {
const folders = await this.stateService.getEncryptedFolders();
const response: Folder[] = [];
for (const id in folders) {
// eslint-disable-next-line
if (folders.hasOwnProperty(id)) {
response.push(new Folder(folders[id]));
}
}
return response;
}
async getAllDecrypted(): Promise<FolderView[]> {
const decryptedFolders = await this.stateService.getDecryptedFolders();
if (decryptedFolders != null) {
return decryptedFolders;
}
const hasKey = await this.cryptoService.hasKey();
if (!hasKey) {
throw new Error("No key.");
}
const decFolders: FolderView[] = [];
const promises: Promise<any>[] = [];
const folders = await this.getAll();
folders.forEach((folder) => {
promises.push(folder.decrypt().then((f) => decFolders.push(f)));
});
await Promise.all(promises);
decFolders.sort(Utils.getSortFunction(this.i18nService, "name"));
const noneFolder = new FolderView();
noneFolder.name = this.i18nService.t("noneFolder");
decFolders.push(noneFolder);
await this.stateService.setDecryptedFolders(decFolders);
return decFolders;
}
async getAllNested(folders?: FolderView[]): Promise<TreeNode<FolderView>[]> {
folders = folders ?? (await this.getAllDecrypted());
const nodes: TreeNode<FolderView>[] = [];
folders.forEach((f) => {
const folderCopy = new FolderView();
folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate;
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter);
});
return nodes;
}
async getNested(id: string): Promise<TreeNode<FolderView>> {
const folders = await this.getAllNested();
return ServiceUtils.getTreeNodeObject(folders, id) as TreeNode<FolderView>;
}
async saveWithServer(folder: Folder): Promise<any> {
const request = new FolderRequest(folder);
let response: FolderResponse;
if (folder.id == null) {
response = await this.apiService.postFolder(request);
folder.id = response.id;
} else {
response = await this.apiService.putFolder(folder.id, request);
}
const data = new FolderData(response);
await this.upsert(data);
}
async upsert(folder: FolderData | FolderData[]): Promise<any> {
let folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
folders = {};
}
if (folder instanceof FolderData) {
const f = folder as FolderData;
folders[f.id] = f;
} else {
(folder as FolderData[]).forEach((f) => {
folders[f.id] = f;
});
}
await this.stateService.setDecryptedFolders(null);
await this.stateService.setEncryptedFolders(folders);
}
async replace(folders: { [id: string]: FolderData }): Promise<any> {
await this.stateService.setDecryptedFolders(null);
await this.stateService.setEncryptedFolders(folders);
}
async clear(userId?: string): Promise<any> {
await this.stateService.setDecryptedFolders(null, { userId: userId });
await this.stateService.setEncryptedFolders(null, { userId: userId });
}
async delete(id: string | string[]): Promise<any> {
const folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
return;
}
if (typeof id === "string") {
if (folders[id] == null) {
return;
}
delete folders[id];
} else {
(id as string[]).forEach((i) => {
delete folders[i];
});
}
await this.stateService.setDecryptedFolders(null);
await this.stateService.setEncryptedFolders(folders);
// Items in a deleted folder are re-assigned to "No Folder"
const ciphers = await this.stateService.getEncryptedCiphers();
if (ciphers != null) {
const updates: CipherData[] = [];
for (const cId in ciphers) {
if (ciphers[cId].folderId === id) {
ciphers[cId].folderId = null;
updates.push(ciphers[cId]);
}
}
if (updates.length > 0) {
this.cipherService.upsert(updates);
}
}
}
async deleteWithServer(id: string): Promise<any> {
await this.apiService.deleteFolder(id);
await this.delete(id);
}
}

View File

@@ -0,0 +1,50 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { FolderData } from "@bitwarden/common/models/data/folderData";
import { Folder } from "@bitwarden/common/models/domain/folder";
import { FolderRequest } from "@bitwarden/common/models/request/folderRequest";
import { FolderResponse } from "@bitwarden/common/models/response/folderResponse";
export class FolderApiService implements FolderApiServiceAbstraction {
constructor(private folderService: InternalFolderService, private apiService: ApiService) {}
async save(folder: Folder): Promise<any> {
const request = new FolderRequest(folder);
let response: FolderResponse;
if (folder.id == null) {
response = await this.postFolder(request);
folder.id = response.id;
} else {
response = await this.putFolder(folder.id, request);
}
const data = new FolderData(response);
await this.folderService.upsert(data);
}
async delete(id: string): Promise<any> {
await this.deleteFolder(id);
await this.folderService.delete(id);
}
async get(id: string): Promise<FolderResponse> {
const r = await this.apiService.send("GET", "/folders/" + id, null, true, true);
return new FolderResponse(r);
}
private async postFolder(request: FolderRequest): Promise<FolderResponse> {
const r = await this.apiService.send("POST", "/folders", request, true, true);
return new FolderResponse(r);
}
async putFolder(id: string, request: FolderRequest): Promise<FolderResponse> {
const r = await this.apiService.send("PUT", "/folders/" + id, request, true, true);
return new FolderResponse(r);
}
private deleteFolder(id: string): Promise<any> {
return this.apiService.send("DELETE", "/folders/" + id, null, true, false);
}
}

View File

@@ -0,0 +1,163 @@
import { BehaviorSubject } from "rxjs";
import { CipherService } from "../../abstractions/cipher.service";
import { CryptoService } from "../../abstractions/crypto.service";
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../abstractions/folder/folder.service.abstraction";
import { I18nService } from "../../abstractions/i18n.service";
import { StateService } from "../../abstractions/state.service";
import { Utils } from "../../misc/utils";
import { CipherData } from "../../models/data/cipherData";
import { FolderData } from "../../models/data/folderData";
import { Folder } from "../../models/domain/folder";
import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey";
import { FolderView } from "../../models/view/folderView";
export class FolderService implements InternalFolderServiceAbstraction {
private _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
private _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
folders$ = this._folders.asObservable();
folderViews$ = this._folderViews.asObservable();
constructor(
private cryptoService: CryptoService,
private i18nService: I18nService,
private cipherService: CipherService,
private stateService: StateService
) {
this.stateService.activeAccountUnlocked.subscribe(async (unlocked) => {
if ((Utils.global as any).bitwardenContainerService == null) {
return;
}
if (!unlocked) {
this._folders.next([]);
this._folderViews.next([]);
return;
}
const data = await this.stateService.getEncryptedFolders();
await this.updateObservables(data);
});
}
async clearCache(): Promise<void> {
this._folderViews.next([]);
}
// TODO: This should be moved to EncryptService or something
async encrypt(model: FolderView, key?: SymmetricCryptoKey): Promise<Folder> {
const folder = new Folder();
folder.id = model.id;
folder.name = await this.cryptoService.encrypt(model.name, key);
return folder;
}
async get(id: string): Promise<Folder> {
const folders = this._folders.getValue();
return folders.find((folder) => folder.id === id);
}
/**
* @deprecated Only use in CLI!
*/
async getAllDecryptedFromState(): Promise<FolderView[]> {
const data = await this.stateService.getEncryptedFolders();
const folders = Object.values(data || {}).map((f) => new Folder(f));
return this.decryptFolders(folders);
}
async upsert(folder: FolderData | FolderData[]): Promise<void> {
let folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
folders = {};
}
if (folder instanceof FolderData) {
const f = folder as FolderData;
folders[f.id] = f;
} else {
(folder as FolderData[]).forEach((f) => {
folders[f.id] = f;
});
}
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
}
async replace(folders: { [id: string]: FolderData }): Promise<void> {
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
}
async clear(userId?: string): Promise<any> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._folders.next([]);
this._folderViews.next([]);
}
await this.stateService.setEncryptedFolders(null, { userId: userId });
}
async delete(id: string | string[]): Promise<any> {
const folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
return;
}
if (typeof id === "string") {
if (folders[id] == null) {
return;
}
delete folders[id];
} else {
(id as string[]).forEach((i) => {
delete folders[i];
});
}
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
// Items in a deleted folder are re-assigned to "No Folder"
const ciphers = await this.stateService.getEncryptedCiphers();
if (ciphers != null) {
const updates: CipherData[] = [];
for (const cId in ciphers) {
if (ciphers[cId].folderId === id) {
ciphers[cId].folderId = null;
updates.push(ciphers[cId]);
}
}
if (updates.length > 0) {
this.cipherService.upsert(updates);
}
}
}
private async updateObservables(foldersMap: { [id: string]: FolderData }) {
const folders = Object.values(foldersMap || {}).map((f) => new Folder(f));
this._folders.next(folders);
if (await this.cryptoService.hasKey()) {
this._folderViews.next(await this.decryptFolders(folders));
}
}
private async decryptFolders(folders: Folder[]) {
const decryptFolderPromises = folders.map((f) => f.decrypt());
const decryptedFolders = await Promise.all(decryptFolderPromises);
decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name"));
const noneFolder = new FolderView();
noneFolder.name = this.i18nService.t("noneFolder");
decryptedFolders.push(noneFolder);
return decryptedFolders;
}
}

View File

@@ -0,0 +1,31 @@
import { FormGroup, ValidationErrors } from "@angular/forms";
import {
FormGroupControls,
FormValidationErrorsService as FormValidationErrorsAbstraction,
AllValidationErrors,
} from "../abstractions/formValidationErrors.service";
export class FormValidationErrorsService implements FormValidationErrorsAbstraction {
getFormValidationErrors(controls: FormGroupControls): AllValidationErrors[] {
let errors: AllValidationErrors[] = [];
Object.keys(controls).forEach((key) => {
const control = controls[key];
if (control instanceof FormGroup) {
errors = errors.concat(this.getFormValidationErrors(control.controls));
}
const controlErrors: ValidationErrors = controls[key].errors;
if (controlErrors !== null) {
Object.keys(controlErrors).forEach((keyError) => {
errors.push({
controlName: key,
errorName: keyError,
});
});
}
});
return errors;
}
}

View File

@@ -1,7 +1,10 @@
import { Observable, ReplaySubject } from "rxjs";
import { I18nService as I18nServiceAbstraction } from "../abstractions/i18n.service";
export class I18nService implements I18nServiceAbstraction {
locale: string;
private _locale = new ReplaySubject<string>(1);
locale$: Observable<string> = this._locale.asObservable();
// First locale is the default (English)
supportedTranslationLocales: string[] = ["en"];
translationLocale: string;
@@ -85,10 +88,14 @@ export class I18nService implements I18nServiceAbstraction {
}
this.inited = true;
this.locale = this.translationLocale = locale != null ? locale : this.systemLanguage;
this.translationLocale = locale != null ? locale : this.systemLanguage;
this._locale.next(this.translationLocale);
try {
this.collator = new Intl.Collator(this.locale, { numeric: true, sensitivity: "base" });
this.collator = new Intl.Collator(this.translationLocale, {
numeric: true,
sensitivity: "base",
});
} catch {
this.collator = null;
}

View File

@@ -2,7 +2,7 @@ import { ApiService } from "../abstractions/api.service";
import { CipherService } from "../abstractions/cipher.service";
import { CollectionService } from "../abstractions/collection.service";
import { CryptoService } from "../abstractions/crypto.service";
import { FolderService } from "../abstractions/folder.service";
import { FolderService } from "../abstractions/folder/folder.service.abstraction";
import { I18nService } from "../abstractions/i18n.service";
import { ImportService as ImportServiceAbstraction } from "../abstractions/import.service";
import { PlatformUtilsService } from "../abstractions/platformUtils.service";

View File

@@ -14,16 +14,23 @@ export class SearchService implements SearchServiceAbstraction {
indexedEntityId?: string = null;
private indexing = false;
private index: lunr.Index = null;
private searchableMinLength = 2;
private readonly immediateSearchLocales: string[] = ["zh-CN", "zh-TW", "ja", "ko", "vi"];
private readonly defaultSearchableMinLength: number = 2;
private searchableMinLength: number = this.defaultSearchableMinLength;
constructor(
private cipherService: CipherService,
private logService: LogService,
private i18nService: I18nService
) {
if (["zh-CN", "zh-TW"].indexOf(i18nService.locale) !== -1) {
this.searchableMinLength = 1;
}
this.i18nService.locale$.subscribe((locale) => {
if (this.immediateSearchLocales.indexOf(locale) !== -1) {
this.searchableMinLength = 1;
} else {
this.searchableMinLength = this.defaultSearchableMinLength;
}
});
//register lunr pipeline function
lunr.Pipeline.registerFunction(this.normalizeAccentsPipelineFunction, "normalizeAccents");
}

View File

@@ -13,6 +13,7 @@ import { StateFactory } from "../factories/stateFactory";
import { Utils } from "../misc/utils";
import { CipherData } from "../models/data/cipherData";
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 { OrganizationData } from "../models/data/organizationData";
@@ -31,7 +32,6 @@ import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
import { WindowState } from "../models/domain/windowState";
import { CipherView } from "../models/view/cipherView";
import { CollectionView } from "../models/view/collectionView";
import { FolderView } from "../models/view/folderView";
import { SendView } from "../models/view/sendView";
const keys = {
@@ -56,6 +56,7 @@ export class StateService<
{
accounts = new BehaviorSubject<{ [userId: string]: TAccount }>({});
activeAccount = new BehaviorSubject<string>(null);
activeAccountUnlocked = new BehaviorSubject<boolean>(false);
private hasBeenInited = false;
private isRecoveredSession = false;
@@ -70,7 +71,21 @@ export class StateService<
protected stateMigrationService: StateMigrationService,
protected stateFactory: StateFactory<TGlobalState, TAccount>,
protected useAccountCache: boolean = true
) {}
) {
// If the account gets changed, verify the new account is unlocked
this.activeAccount.subscribe(async (userId) => {
if (userId == null && this.activeAccountUnlocked.getValue() == false) {
return;
} else if (userId == null) {
this.activeAccountUnlocked.next(false);
}
// FIXME: This should be refactored into AuthService or a similar service,
// as checking for the existance of the crypto key is a low level
// implementation detail.
this.activeAccountUnlocked.next((await this.getCryptoMasterKey()) != null);
});
}
async init(): Promise<void> {
if (this.hasBeenInited) {
@@ -499,6 +514,15 @@ export class StateService<
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
if (options.userId == this.activeAccount.getValue()) {
const nextValue = value != null;
// Avoid emitting if we are already unlocked
if (this.activeAccountUnlocked.getValue() != nextValue) {
this.activeAccountUnlocked.next(nextValue);
}
}
}
async getCryptoMasterKeyAuto(options?: StorageOptions): Promise<string> {
@@ -658,24 +682,6 @@ export class StateService<
);
}
@withPrototypeForArrayMembers(FolderView)
async getDecryptedFolders(options?: StorageOptions): Promise<FolderView[]> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.data?.folders?.decrypted;
}
async setDecryptedFolders(value: FolderView[], options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
account.data.folders.decrypted = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
}
@withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.initFromJson)
async getDecryptedOrganizationKeys(
options?: StorageOptions
@@ -1363,14 +1369,16 @@ export class StateService<
);
}
async getEncryptedOrganizationKeys(options?: StorageOptions): Promise<any> {
async getEncryptedOrganizationKeys(
options?: StorageOptions
): Promise<{ [orgId: string]: EncryptedOrganizationKeyData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.keys?.organizationKeys.encrypted;
}
async setEncryptedOrganizationKeys(
value: Map<string, SymmetricCryptoKey>,
value: { [orgId: string]: EncryptedOrganizationKeyData },
options?: StorageOptions
): Promise<void> {
const account = await this.getAccount(
@@ -1940,52 +1948,52 @@ export class StateService<
async getPasswordGenerationOptions(options?: StorageOptions): Promise<any> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.passwordGenerationOptions;
}
async setPasswordGenerationOptions(value: any, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
account.settings.passwordGenerationOptions = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
}
async getUsernameGenerationOptions(options?: StorageOptions): Promise<any> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.usernameGenerationOptions;
}
async setUsernameGenerationOptions(value: any, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
account.settings.usernameGenerationOptions = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
}
async getGeneratorOptions(options?: StorageOptions): Promise<any> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.generatorOptions;
}
async setGeneratorOptions(value: any, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
account.settings.generatorOptions = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
}

View File

@@ -155,6 +155,15 @@ export class StateMigrationService<
case StateVersion.Three:
await this.migrateStateFrom3To4();
break;
case StateVersion.Four: {
const authenticatedAccounts = await this.getAuthenticatedAccounts();
for (const account of authenticatedAccounts) {
const migratedAccount = await this.migrateAccountFrom4To5(account);
await this.set(account.profile.userId, migratedAccount);
}
await this.setCurrentStateVersion(StateVersion.Five);
break;
}
}
currentStateVersion += 1;
@@ -488,6 +497,20 @@ export class StateMigrationService<
await this.set(keys.global, globals);
}
protected async migrateAccountFrom4To5(account: TAccount): Promise<TAccount> {
const encryptedOrgKeys = account.keys?.organizationKeys?.encrypted;
if (encryptedOrgKeys != null) {
for (const [orgId, encKey] of Object.entries(encryptedOrgKeys)) {
encryptedOrgKeys[orgId] = {
type: "organization",
key: encKey as unknown as string, // Account v4 does not reflect the current account model so we have to cast
};
}
}
return account;
}
protected get options(): StorageOptions {
return { htmlStorageLocation: HtmlStorageLocation.Local };
}
@@ -510,4 +533,15 @@ export class StateMigrationService<
protected async getCurrentStateVersion(): Promise<StateVersion> {
return (await this.getGlobals())?.stateVersion ?? StateVersion.One;
}
protected async setCurrentStateVersion(newVersion: StateVersion): Promise<void> {
const globals = await this.getGlobals();
globals.stateVersion = newVersion;
await this.set(keys.global, globals);
}
protected async getAuthenticatedAccounts(): Promise<TAccount[]> {
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
return Promise.all(authenticatedUserIds.map((id) => this.get<TAccount>(id)));
}
}

View File

@@ -2,7 +2,8 @@ import { ApiService } from "../abstractions/api.service";
import { CipherService } from "../abstractions/cipher.service";
import { CollectionService } from "../abstractions/collection.service";
import { CryptoService } from "../abstractions/crypto.service";
import { FolderService } from "../abstractions/folder.service";
import { FolderApiServiceAbstraction } from "../abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../abstractions/folder/folder.service.abstraction";
import { KeyConnectorService } from "../abstractions/keyConnector.service";
import { LogService } from "../abstractions/log.service";
import { MessagingService } from "../abstractions/messaging.service";
@@ -40,7 +41,7 @@ export class SyncService implements SyncServiceAbstraction {
constructor(
private apiService: ApiService,
private settingsService: SettingsService,
private folderService: FolderService,
private folderService: InternalFolderService,
private cipherService: CipherService,
private cryptoService: CryptoService,
private collectionService: CollectionService,
@@ -52,6 +53,7 @@ export class SyncService implements SyncServiceAbstraction {
private stateService: StateService,
private organizationService: OrganizationService,
private providerService: ProviderService,
private folderApiService: FolderApiServiceAbstraction,
private logoutCallback: (expired: boolean) => Promise<void>
) {}
@@ -127,7 +129,7 @@ export class SyncService implements SyncServiceAbstraction {
(!isEdit && localFolder == null) ||
(isEdit && localFolder != null && localFolder.revisionDate < notification.revisionDate)
) {
const remoteFolder = await this.apiService.getFolder(notification.id);
const remoteFolder = await this.folderApiService.get(notification.id);
if (remoteFolder != null) {
await this.folderService.upsert(new FolderData(remoteFolder));
this.messagingService.send("syncedUpsertedFolder", { folderId: notification.id });
@@ -196,7 +198,7 @@ export class SyncService implements SyncServiceAbstraction {
}
if (shouldUpdate) {
const remoteCipher = await this.apiService.getCipher(notification.id);
const remoteCipher = await this.apiService.getFullCipherDetails(notification.id);
if (remoteCipher != null) {
await this.cipherService.upsert(new CipherData(remoteCipher));
this.messagingService.send("syncedUpsertedCipher", { cipherId: notification.id });

View File

@@ -2,7 +2,7 @@ import { AuthService } from "../abstractions/auth.service";
import { CipherService } from "../abstractions/cipher.service";
import { CollectionService } from "../abstractions/collection.service";
import { CryptoService } from "../abstractions/crypto.service";
import { FolderService } from "../abstractions/folder.service";
import { FolderService } from "../abstractions/folder/folder.service.abstraction";
import { KeyConnectorService } from "../abstractions/keyConnector.service";
import { MessagingService } from "../abstractions/messaging.service";
import { PlatformUtilsService } from "../abstractions/platformUtils.service";
@@ -80,6 +80,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
if (userId == null || userId === (await this.stateService.getUserId())) {
this.searchService.clearIndex();
await this.folderService.clearCache();
}
await this.stateService.setEverBeenUnlocked(true, { userId: userId });
@@ -91,7 +92,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.cryptoService.clearKeyPair(true, userId);
await this.cryptoService.clearEncKey(true, userId);
await this.folderService.clearCache(userId);
await this.cipherService.clearCache(userId);
await this.collectionService.clearCache(userId);

View File

@@ -26,11 +26,10 @@ const hoverStyles: Record<BadgeTypes, string[]> = {
export class BadgeDirective {
@HostBinding("class") get classList() {
return [
"tw-inline-block",
"tw-py-1",
"tw-inline",
"tw-py-0.5",
"tw-px-1.5",
"tw-font-bold",
"tw-leading-none",
"tw-text-center",
"!tw-text-contrast",
"tw-rounded",

View File

@@ -26,6 +26,8 @@ export class BitErrorComponent {
return this.i18nService.t("inputRequired");
case "email":
return this.i18nService.t("inputEmail");
case "minlength":
return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength);
default:
// Attempt to show a custom error message.
if (this.error[1]?.message) {

View File

@@ -180,6 +180,27 @@ const ButtonGroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldCom
export const ButtonInputGroup = ButtonGroupTemplate.bind({});
ButtonInputGroup.args = {};
const DisabledButtonInputGroupTemplate: Story<BitFormFieldComponent> = (
args: BitFormFieldComponent
) => ({
props: args,
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" disabled />
<button bitSuffix bitButton disabled>
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
</button>
<button bitSuffix bitButton>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
</bit-form-field>
`,
});
export const DisabledButtonInputGroup = DisabledButtonInputGroupTemplate.bind({});
DisabledButtonInputGroup.args = {};
const SelectTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: args,
template: `

View File

@@ -10,6 +10,8 @@ export const PrefixClasses = [
"tw-border-secondary-500",
"tw-text-muted",
"tw-rounded-none",
"disabled:!tw-text-muted",
"disabled:tw-border-secondary-500",
];
@Directive({

View File

@@ -1,8 +1,10 @@
export * from "./badge";
export * from "./banner";
export * from "./button";
export * from "./toggle-group";
export * from "./callout";
export * from "./form-field";
export * from "./menu";
export * from "./utils/i18n-mock.service";
export * from "./tabs";
export * from "./submit-button";

View File

@@ -0,0 +1,188 @@
<!-- Iconography.stories.mdx -->
import { Meta } from "@storybook/addon-docs/";
<Meta title="Common/Icons" />
# Iconography
Avoid using icons to convey information unless paired with meaningful, clear text. If an icon must be used and text cannot be displayed visually along with the icon, use an `aria-label` to provide the text to screen readers, and a `title` attribute to provide the text visually through a tool tip. Note: this pattern should only be followed for very common iconography such as, a settings cog icon or an options menu icon.
## Status Indicators
| Icon | bwi-name | Usage |
| -------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <i class="bwi bwi-ban"></i> | bwi-ban | option or feature not available. Example: send maximum access count was reached |
| <i class="bwi bwi-check"></i> | bwi-check | confirmation action (Example: "confirm member"), successful confirmation (toast or callout), or shows currently selected option in a menu. Use with success color variable if applicable. |
| <i class="bwi bwi-error"></i> | bwi-error | error; used in form field error states and error toasts, banners, and callouts. Do not use as a close or clear icon. Use with danger color variable. |
| <i class="bwi bwi-exclamation-circle"></i> | bwi-exclamation-circle | deprecated error icon; use bwi-error |
| <i class="bwi bwi-exclamation-triangle"></i> | bwi-exclamation-triangle | warning; used in warning callouts, banners, and toasts. Use with warning color variable. |
| <i class="bwi bwi-info-circle"></i> | bwi-info-circle | information; used in info callouts, banners, and toasts. Use with info color variable. |
| <i class="bwi bwi-question-circle"></i> | bwi-question-circle | link to help documentation or hover tooltip |
| <i class="bwi bwi-spinner"></i> | bwi-spinner | loading |
## Bitwarden Objects
| Icon | bwi-name | Usage |
| ----------------------------------- | --------------- | --------------------------------------------------- |
| <i class="bwi bwi-business"></i> | bwi-business | organization or vault for Free, Teams or Enterprise |
| <i class="bwi bwi-collection"></i> | bwi-collection | collection |
| <i class="bwi bwi-credit-card"></i> | bwi-credit-card | card item type |
| <i class="bwi bwi-family"></i> | bwi-family | family vault or organization |
| <i class="bwi bwi-folder"></i> | bwi-folder | folder |
| <i class="bwi bwi-globe"></i> | bwi-globe | login item type |
| <i class="bwi bwi-id-card"></i> | bwi-id-card | identity item type |
| <i class="bwi bwi-send"></i> | bwi-send | send action or feature |
| <i class="bwi bwi-send-f"></i> | bwi-send-f | - |
| <i class="bwi bwi-sticky-note"></i> | bwi-sticky-note | secure note item type |
| <i class="bwi bwi-users"></i> | bwi-users | user group |
| <i class="bwi bwi-vault"></i> | bwi-vault | general vault |
## Actions
| Icon | bwi-name | Usage |
| ------------------------------------- | ----------------- | -------------------------------------------- |
| <i class="bwi bwi-check-circle"></i> | bwi-check-circle | check if password has been exposed |
| <i class="bwi bwi-check-square"></i> | bwi-check-square | select all action |
| <i class="bwi bwi-clone"></i> | bwi-clone | copy to clipboard action |
| <i class="bwi bwi-close"></i> | bwi-close | close action |
| <i class="bwi bwi-cog"></i> | bwi-cog | settings |
| <i class="bwi bwi-cog-f"></i> | bwi-cog-f | settings |
| <i class="bwi bwi-cogs"></i> | bwi-cogs | deprecated; do not use in app. |
| <i class="bwi bwi-download"></i> | bwi-download | download or export |
| <i class="bwi bwi-envelope"></i> | bwi-envelope | action related to emailing a user |
| <i class="bwi bwi-external-link"></i> | bwi-external-link | open in new window or popout |
| <i class="bwi bwi-eye"></i> | bwi-eye | show icon for password fields |
| <i class="bwi bwi-eye-slash"></i> | bwi-eye-slash | hide icon for password fields |
| <i class="bwi bwi-files"></i> | bwi-files | clone action / duplicate an item |
| <i class="bwi bwi-generate"></i> | bwi-generate | generate action in edit item forms |
| <i class="bwi bwi-generate-f"></i> | bwi-generate-f | generate feature or action |
| <i class="bwi bwi-lock"></i> | bwi-lock | lock vault action |
| <i class="bwi bwi-lock-f"></i> | bwi-lock-f | - |
| <i class="bwi bwi-minus-circle"></i> | bwi-minus-circle | remove action |
| <i class="bwi bwi-minus-square"></i> | bwi-minus-square | unselect all action |
| <i class="bwi bwi-paste"></i> | bwi-paste | paste from clipbaord action |
| <i class="bwi bwi-pencil-square"></i> | bwi-pencil-square | edit action |
| <i class="bwi bwi-play"></i> | bwi-play | start or play action |
| <i class="bwi bwi-plus"></i> | bwi-plus | new or add option in contained buttons/links |
| <i class="bwi bwi-plus-circle"></i> | bwi-plus-circle | new or add option in text buttons/links |
| <i class="bwi bwi-plus-square"></i> | bwi-plus-square | - |
| <i class="bwi bwi-refresh"></i> | bwi-refresh | "re"-action; such as refresh or regenerate |
| <i class="bwi bwi-refresh-tab"></i> | bwi-refresh-tab | - |
| <i class="bwi bwi-save"></i> | bwi-save | alternate download action |
| <i class="bwi bwi-save-changes"></i> | bwi-save-changes | save changes action |
| <i class="bwi bwi-search"></i> | bwi-search | search action |
| <i class="bwi bwi-share"></i> | bwi-share | - |
| <i class="bwi bwi-share-arrow"></i> | bwi-share-arrow | - |
| <i class="bwi bwi-share-square"></i> | bwi-share-square | avoid using; use external-link instead |
| <i class="bwi bwi-sign-in"></i> | bwi-sign-in | sign-in action |
| <i class="bwi bwi-sign-out"></i> | bwi-sign-out | sign-out action |
| <i class="bwi bwi-star"></i> | bwi-star | favorite action |
| <i class="bwi bwi-star-f"></i> | bwi-star-f | favorited / unfavorite action |
| <i class="bwi bwi-stop"></i> | bwi-stop | stop action |
| <i class="bwi bwi-trash"></i> | bwi-trash | delete action or trash area |
| <i class="bwi bwi-undo"></i> | bwi-undo | restore action |
| <i class="bwi bwi-unlock"></i> | bwi-unlock | unlocked |
## Directional and Menu Indicators
| Icon | bwi-name | Usage |
| ------------------------------------------ | ---------------------- | ------------------------------------------------------- |
| <i class="bwi bwi-angle-down"></i> | bwi-angle-down | drop down or expandable options |
| <i class="bwi bwi-angle-left"></i> | bwi-angle-left | - |
| <i class="bwi bwi-angle-right"></i> | bwi-angle-right | collapsed section that can be expanded |
| <i class="bwi bwi-arrow-circle-down"></i> | bwi-arrow-circle-down | table sort order |
| <i class="bwi bwi-arrow-circle-left"></i> | bwi-arrow-circle-left | - |
| <i class="bwi bwi-arrow-circle-right"></i> | bwi-arrow-circle-right | - |
| <i class="bwi bwi-arrow-circle-up"></i> | bwi-arrow-circle-up | table sort order |
| <i class="bwi bwi-caret-down"></i> | bwi-caret-down | - |
| <i class="bwi bwi-caret-right"></i> | bwi-caret-right | - |
| <i class="bwi bwi-chevron-up"></i> | bwi-chevron-up | - |
| <i class="bwi bwi-dbl-angle-left"></i> | bwi-dbl-angle-left | - |
| <i class="bwi bwi-dbl-angle-right"></i> | bwi-dbl-angle-right | - |
| <i class="bwi bwi-ellipsis-h"></i> | bwi-ellipsis-h | more options menu horizontal; used in mobile list items |
| <i class="bwi bwi-ellipsis-v"></i> | bwi-ellipsis-v | more optioins menu vertical; used primarily in tables |
| <i class="bwi bwi-filter"></i> | bwi-filter | Product switcher |
| <i class="bwi bwi-hamburger"></i> | bwi-hamburger | navigation indicator |
| <i class="bwi bwi-list"></i> | bwi-list | toggle list/grid view |
| <i class="bwi bwi-list-alt"></i> | bwi-list-alt | view item action in extension |
| <i class="bwi bwi-long-arrow-right"></i> | bwi-long-arrow-right | - |
| <i class="bwi bwi-numbered-list"></i> | bwi-numbered-list | toggle numbered list view |
## Misc Objects
| Icon | bwi-name | Usage |
| ----------------------------------------- | --------------------- | ---------------------------------------------- |
| <i class="bwi bwi-bank"></i> | bwi-bank | - |
| <i class="bwi bwi-billing"></i> | bwi-billing | billing options |
| <i class="bwi bwi-bitcoin"></i> | bwi-bitcoin | crypto |
| <i class="bwi bwi-bolt"></i> | bwi-bolt | deprecated "danger" icon |
| <i class="bwi bwi-bookmark"></i> | bwi-bookmark | bookmark or save related actions |
| <i class="bwi bwi-browser"></i> | bwi-browser | web browser |
| <i class="bwi bwi-bug"></i> | bwi-bug | test or debug action |
| <i class="bwi bwi-camera"></i> | bwi-camera | actions related to camera use |
| <i class="bwi bwi-chain-broken"></i> | bwi-chain-broken | unlink action |
| <i class="bwi bwi-chat"></i> | bwi-chat | - |
| <i class="bwi bwi-cli"></i> | bwi-cli | cli client or code |
| <i class="bwi bwi-clock"></i> | bwi-clock | use for time based actions or views |
| <i class="bwi bwi-cut"></i> | bwi-cut | cut or omit actions |
| <i class="bwi bwi-dashboard"></i> | bwi-dashboard | statuses or dashboard views |
| <i class="bwi bwi-desktop"></i> | bwi-desktop | desktop client |
| <i class="bwi bwi-dollar"></i> | bwi-dollar | account credit |
| <i class="bwi bwi-file"></i> | bwi-file | file related objects or actions |
| <i class="bwi bwi-file-pdf"></i> | bwi-file-pdf | PDF related object or actions |
| <i class="bwi bwi-file-text"></i> | bwi-file-text | text related objects or actions |
| <i class="bwi bwi-bw-folder-open-f1"></i> | bwi-bw-folder-open-f1 | - |
| <i class="bwi bwi-folder-closed-f"></i> | bwi-folder-closed-f | - |
| <i class="bwi bwi-folder-open"></i> | bwi-folder-open | - |
| <i class="bwi bwi-frown"></i> | bwi-frown | - |
| <i class="bwi bwi-hashtag"></i> | bwi-hashtag | link to specific id |
| <i class="bwi bwi-key"></i> | bwi-key | key or password related objects or actions |
| <i class="bwi bwi-learning"></i> | bwi-learning | learning center |
| <i class="bwi bwi-lightbulb"></i> | bwi-lightbulb | - |
| <i class="bwi bwi-link"></i> | bwi-link | link action |
| <i class="bwi bwi-mobile"></i> | bwi-mobile | mobile client |
| <i class="bwi bwi-money"></i> | bwi-money | - |
| <i class="bwi bwi-paperclip"></i> | bwi-paperclip | attachments |
| <i class="bwi bwi-pencil"></i> | bwi-pencil | editing |
| <i class="bwi bwi-provider"></i> | bwi-provider | relates to provider or provider portal |
| <i class="bwi bwi-providers"></i> | bwi-providers | - |
| <i class="bwi bwi-puzzle"></i> | bwi-puzzle | - |
| <i class="bwi bwi-rocket"></i> | bwi-rocket | - |
| <i class="bwi bwi-rss"></i> | bwi-rss | - |
| <i class="bwi bwi-server"></i> | bwi-server | - |
| <i class="bwi bwi-shield"></i> | bwi-shield | - |
| <i class="bwi bwi-sitemap"></i> | bwi-sitemap | - |
| <i class="bwi bwi-sliders"></i> | bwi-sliders | reporting or filtering |
| <i class="bwi bwi-square"></i> | bwi-square | - |
| <i class="bwi bwi-tag"></i> | bwi-tag | labels |
| <i class="bwi bwi-thumb-tack"></i> | bwi-thumb-tack | - |
| <i class="bwi bwi-thumbs-up"></i> | bwi-thumbs-up | - |
| <i class="bwi bwi-universal-access"></i> | bwi-universal-access | use for accessiblity related actions |
| <i class="bwi bwi-user"></i> | bwi-user | relates to current user or organization member |
| <i class="bwi bwi-user-circle"></i> | bwi-user-circle | - |
| <i class="bwi bwi-user-f"></i> | bwi-user-f | - |
| <i class="bwi bwi-wrench"></i> | bwi-wrench | tools or aditional configuration options |
## Platforms and Logos
| Icon | bwi-name | Usage |
| --------------------------------- | ------------- | ---------------------------- |
| <i class="bwi bwi-android"></i> | bwi-android | android support |
| <i class="bwi bwi-apple"></i> | bwi-apple | apple/IOS support |
| <i class="bwi bwi-chrome"></i> | bwi-chrome | chrome support |
| <i class="bwi bwi-discourse"></i> | bwi-discourse | community forum |
| <i class="bwi bwi-edge"></i> | bwi-edge | edge support |
| <i class="bwi bwi-facebook"></i> | bwi-facebook | link to our facebook page |
| <i class="bwi bwi-firefox"></i> | bwi-firefox | support for firefox |
| <i class="bwi bwi-github"></i> | bwi-github | link to our github page |
| <i class="bwi bwi-google"></i> | bwi-google | link to our google page |
| <i class="bwi bwi-linkedin"></i> | bwi-linkedin | link to our linkedIn page |
| <i class="bwi bwi-linux"></i> | bwi-linux | linux support |
| <i class="bwi bwi-opera"></i> | bwi-opera | support for Opera |
| <i class="bwi bwi-paypal"></i> | bwi-paypal | PayPal |
| <i class="bwi bwi-reddit"></i> | bwi-reddit | link to our reddit community |
| <i class="bwi bwi-safari"></i> | bwi-safari | safari support |
| <i class="bwi bwi-twitter"></i> | bwi-twitter | link to our twitter page |
| <i class="bwi bwi-windows"></i> | bwi-windows | support for windows |
| <i class="bwi bwi-youtube"></i> | bwi-youtube | link to our youtube page |

View File

@@ -0,0 +1,3 @@
export * from "./tabs.module";
export * from "./tab-group.component";
export * from "./tab-item.component";

View File

@@ -0,0 +1,6 @@
<div
role="tablist"
class="tw-inline-flex tw-flex-wrap tw-leading-5 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<ng-content></ng-content>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "bit-tab-group",
templateUrl: "./tab-group.component.html",
})
export class TabGroupComponent {}

View File

@@ -0,0 +1,26 @@
<ng-container [ngSwitch]="disabled">
<a
*ngSwitchCase="false"
role="tab"
[class]="baseClassList"
[routerLink]="route"
[routerLinkActive]="activeClassList"
#rla="routerLinkActive"
[attr.aria-selected]="rla.isActive"
>
<ng-container [ngTemplateOutlet]="content"></ng-container>
</a>
<button
*ngSwitchCase="true"
type="button"
role="tab"
[class]="baseClassList"
disabled
aria-disabled="true"
>
<ng-container [ngTemplateOutlet]="content"></ng-container>
</button>
</ng-container>
<ng-template #content>
<ng-content></ng-content>
</ng-template>

View File

@@ -0,0 +1,54 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "bit-tab-item",
templateUrl: "./tab-item.component.html",
})
export class TabItemComponent {
@Input() route: string; // ['/route']
@Input() disabled = false;
get baseClassList(): string[] {
return [
"tw-block",
"tw-relative",
"tw-py-2",
"tw-px-4",
"tw-font-semibold",
"tw-transition",
"tw-rounded-t",
"tw-border-0",
"tw-border-x",
"tw-border-t-4",
"tw-border-transparent",
"tw-border-solid",
"!tw-text-main",
"hover:tw-underline",
"hover:!tw-text-main",
"focus:tw-z-10",
"focus:tw-outline-none",
"focus:tw-ring-2",
"focus:tw-ring-primary-700",
"disabled:tw-bg-secondary-100",
"disabled:!tw-text-muted",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
];
}
get activeClassList(): string {
return [
"tw--mb-px",
"tw-border-x-secondary-300",
"tw-border-t-primary-500",
"tw-border-b",
"tw-border-b-background",
"tw-bg-background",
"!tw-text-primary-500",
"hover:tw-border-t-primary-700",
"hover:!tw-text-primary-700",
"focus:tw-border-t-primary-700",
"focus:!tw-text-primary-700",
].join(" ");
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { TabGroupComponent } from "./tab-group.component";
import { TabItemComponent } from "./tab-item.component";
@NgModule({
imports: [CommonModule, RouterModule],
exports: [TabGroupComponent, TabItemComponent],
declarations: [TabGroupComponent, TabItemComponent],
})
export class TabsModule {}

View File

@@ -0,0 +1,84 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { TabGroupComponent } from "./tab-group.component";
import { TabItemComponent } from "./tab-item.component";
@Component({
selector: "bit-tab-active-dummy",
template: "Router - Active selected",
})
class ActiveDummyComponent {}
@Component({
selector: "bit-tab-item-2-dummy",
template: "Router - Item 2 selected",
})
class ItemTwoDummyComponent {}
@Component({
selector: "bit-tab-item-3-dummy",
template: "Router - Item 3 selected",
})
class ItemThreeDummyComponent {}
@Component({
selector: "bit-tab-disabled-dummy",
template: "Router - Disabled selected",
})
class DisabledDummyComponent {}
export default {
title: "Component Library/Tabs",
component: TabGroupComponent,
decorators: [
moduleMetadata({
declarations: [
TabGroupComponent,
TabItemComponent,
ActiveDummyComponent,
ItemTwoDummyComponent,
ItemThreeDummyComponent,
DisabledDummyComponent,
],
imports: [
CommonModule,
RouterModule.forRoot(
[
{ path: "", redirectTo: "active", pathMatch: "full" },
{ path: "active", component: ActiveDummyComponent },
{ path: "item-2", component: ItemTwoDummyComponent },
{ path: "item-3", component: ItemThreeDummyComponent },
{ path: "disabled", component: DisabledDummyComponent },
],
{ useHash: true }
),
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A17922",
},
},
} as Meta;
const TabGroupTemplate: Story<TabGroupComponent> = (args: TabGroupComponent) => ({
props: args,
template: `
<bit-tab-group>
<bit-tab-item [route]="['active']">Active</bit-tab-item>
<bit-tab-item [route]="['item-2']">Item 2</bit-tab-item>
<bit-tab-item [route]="['item-3']">Item 3</bit-tab-item>
<bit-tab-item [route]="['disabled']" [disabled]="true">Disabled</bit-tab-item>
</bit-tab-group>
<div class="tw-bg-transparent tw-text-semibold tw-text-center !tw-text-main tw-py-10">
<router-outlet></router-outlet>
</div>
`,
});
export const TabGroup = TabGroupTemplate.bind({});

View File

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

View File

@@ -0,0 +1 @@
<ng-content></ng-content>

View File

@@ -0,0 +1,69 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ToggleGroupModule } from "./toggle-group.module";
import { ToggleComponent } from "./toggle.component";
describe("Button", () => {
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let buttonElements: ToggleComponent[];
let radioButtons: HTMLInputElement[];
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [ToggleGroupModule],
declarations: [TestApp],
});
TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;
buttonElements = fixture.debugElement
.queryAll(By.css("bit-toggle"))
.map((e) => e.componentInstance);
radioButtons = fixture.debugElement
.queryAll(By.css("input[type=radio]"))
.map((e) => e.nativeElement);
fixture.detectChanges();
}));
it("should select second element when setting selected to second", () => {
testAppComponent.selected = "second";
fixture.detectChanges();
expect(buttonElements[1].selected).toBe(true);
});
it("should not select second element when setting selected to third", () => {
testAppComponent.selected = "third";
fixture.detectChanges();
expect(buttonElements[1].selected).toBe(false);
});
it("should emit new value when changing selection by clicking on radio button", () => {
testAppComponent.selected = "first";
fixture.detectChanges();
radioButtons[1].click();
expect(testAppComponent.selected).toBe("second");
});
});
@Component({
selector: "test-app",
template: `
<bit-toggle-group [(selected)]="selected">
<bit-toggle value="first">First</bit-toggle>
<bit-toggle value="second">Second</bit-toggle>
<bit-toggle value="third">Third</bit-toggle>
</bit-toggle-group>
`,
})
class TestApp {
selected?: string;
}

View File

@@ -0,0 +1,24 @@
import { Component, EventEmitter, HostBinding, Input, Output } from "@angular/core";
let nextId = 0;
@Component({
selector: "bit-toggle-group",
templateUrl: "./toggle-group.component.html",
preserveWhitespaces: false,
})
export class ToggleGroupComponent {
private id = nextId++;
name = `bit-toggle-group-${this.id}`;
@Input() selected?: unknown;
@Output() selectedChange = new EventEmitter<unknown>();
@HostBinding("attr.role") role = "radiogroup";
@HostBinding("class") classList = ["tw-flex"];
onInputInteraction(value: unknown) {
this.selected = value;
this.selectedChange.emit(value);
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { BadgeModule } from "../badge";
import { ToggleGroupComponent } from "./toggle-group.component";
import { ToggleComponent } from "./toggle.component";
@NgModule({
imports: [CommonModule, BadgeModule],
exports: [ToggleGroupComponent, ToggleComponent],
declarations: [ToggleGroupComponent, ToggleComponent],
})
export class ToggleGroupModule {}

View File

@@ -0,0 +1,54 @@
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { BadgeModule } from "../badge";
import { ToggleGroupComponent } from "./toggle-group.component";
import { ToggleComponent } from "./toggle.component";
export default {
title: "Component Library/Toggle Group",
component: ToggleGroupComponent,
args: {
selected: "all",
},
decorators: [
moduleMetadata({
declarations: [ToggleGroupComponent, ToggleComponent],
imports: [BadgeModule],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A17157",
},
},
} as Meta;
const Template: Story<ToggleGroupComponent> = (args: ToggleGroupComponent) => ({
props: args,
template: `
<bit-toggle-group [(selected)]="selected" aria-label="People list filter">
<bit-toggle value="all">
All <span bitBadge badgeType="info">3</span>
</bit-toggle>
<bit-toggle value="invited">
Invited
</bit-toggle>
<bit-toggle value="accepted">
Accepted <span bitBadge badgeType="info">2</span>
</bit-toggle>
<bit-toggle value="deactivated">
Deactivated
</bit-toggle>
</bit-toggle-group>
`,
});
export const Default = Template.bind({});
Default.args = {
selected: "all",
};

View File

@@ -0,0 +1,11 @@
<input
type="radio"
id="bit-toggle-{{ id }}"
[name]="name"
[ngClass]="inputClasses"
[checked]="selected"
(change)="onInputInteraction()"
/>
<label for="bit-toggle-{{ id }}" [ngClass]="labelClasses">
<ng-content></ng-content>
</label>

View File

@@ -0,0 +1,71 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ToggleGroupComponent } from "./toggle-group.component";
import { ToggleGroupModule } from "./toggle-group.module";
describe("Button", () => {
let mockGroupComponent: MockedButtonGroupComponent;
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let radioButton: HTMLInputElement;
beforeEach(waitForAsync(() => {
mockGroupComponent = new MockedButtonGroupComponent();
TestBed.configureTestingModule({
imports: [ToggleGroupModule],
declarations: [TestApp],
providers: [{ provide: ToggleGroupComponent, useValue: mockGroupComponent }],
});
TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
testAppComponent = fixture.debugElement.componentInstance;
radioButton = fixture.debugElement.query(By.css("input[type=radio]")).nativeElement;
}));
it("should emit value when clicking on radio button", () => {
testAppComponent.value = "value";
fixture.detectChanges();
radioButton.click();
fixture.detectChanges();
expect(mockGroupComponent.onInputInteraction).toHaveBeenCalledWith("value");
});
it("should check radio button when selected matches value", () => {
testAppComponent.value = "value";
fixture.detectChanges();
mockGroupComponent.selected = "value";
fixture.detectChanges();
expect(radioButton.checked).toBe(true);
});
it("should not check radio button when selected does not match value", () => {
testAppComponent.value = "value";
fixture.detectChanges();
mockGroupComponent.selected = "nonMatchingValue";
fixture.detectChanges();
expect(radioButton.checked).toBe(false);
});
});
class MockedButtonGroupComponent implements Partial<ToggleGroupComponent> {
onInputInteraction = jest.fn();
selected = null;
}
@Component({
selector: "test-app",
template: ` <bit-toggle [value]="value">Element</bit-toggle>`,
})
class TestApp {
value?: string;
}

View File

@@ -0,0 +1,80 @@
import { HostBinding, Component, Input } from "@angular/core";
import { ToggleGroupComponent } from "./toggle-group.component";
let nextId = 0;
@Component({
selector: "bit-toggle",
templateUrl: "./toggle.component.html",
preserveWhitespaces: false,
})
export class ToggleComponent {
id = nextId++;
@Input() value?: string;
constructor(private groupComponent: ToggleGroupComponent) {}
@HostBinding("tabIndex") tabIndex = "-1";
@HostBinding("class") classList = ["tw-group"];
get name() {
return this.groupComponent.name;
}
get selected() {
return this.groupComponent.selected === this.value;
}
get inputClasses() {
return ["tw-peer", "tw-appearance-none", "tw-outline-none"];
}
get labelClasses() {
return [
"!tw-font-semibold",
"tw-transition",
"tw-text-center",
"tw-border-text-muted",
"!tw-text-muted",
"tw-border-solid",
"tw-border-y",
"tw-border-r",
"tw-border-l-0",
"tw-cursor-pointer",
"group-first-of-type:tw-border-l",
"group-first-of-type:tw-rounded-l",
"group-last-of-type:tw-rounded-r",
"peer-focus:tw-outline-none",
"peer-focus:tw-ring",
"peer-focus:tw-ring-offset-2",
"peer-focus:tw-ring-primary-500",
"peer-focus:tw-z-10",
"peer-focus:tw-bg-primary-500",
"peer-focus:tw-border-primary-500",
"peer-focus:!tw-text-contrast",
"hover:tw-no-underline",
"hover:tw-bg-text-muted",
"hover:tw-border-text-muted",
"hover:!tw-text-contrast",
"peer-checked:tw-bg-primary-500",
"peer-checked:tw-border-primary-500",
"peer-checked:!tw-text-contrast",
"tw-py-1.5",
"tw-px-3",
// Fix for badge being pushed slightly lower when inside a button.
// Insipired by bootstrap, which does the same.
"[&>[bitBadge]]:tw-relative",
"[&>[bitBadge]]:-tw-top-[1px]",
];
}
onInputInteraction() {
this.groupComponent.onInputInteraction(this.value);
}
}

View File

@@ -1,7 +1,9 @@
import { Observable } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
export class I18nMockService implements I18nService {
locale: string;
locale$: Observable<string>;
supportedTranslationLocales: string[];
translationLocale: string;
collator: Intl.Collator;

View File

@@ -60,6 +60,8 @@ module.exports = {
info: "var(--color-info-500)",
primary: {
300: "var(--color-primary-300)",
500: "var(--color-primary-500)",
700: "var(--color-primary-700)",
},
},
ringOffsetColor: ({ theme }) => ({

View File

@@ -1,29 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier"
],
"rules": {
"@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled
"@typescript-eslint/no-unused-vars": ["warn", { "args": "none" }],
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
"accessibility": "no-public"
}
],
"@typescript-eslint/no-this-alias": [
"error",
{
"allowedNames": ["self"]
}
],
"no-console": "warn",
"import/no-unresolved": "off" // TODO: Look into turning off once each package is an actual package.
}
}