1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

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

This commit is contained in:
Shane Melton
2022-10-11 13:28:19 -07:00
140 changed files with 3451 additions and 683 deletions

View File

@@ -1,5 +1,5 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Observable } from "rxjs";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Observable, Subject, takeUntil, concatMap } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
@@ -33,7 +33,7 @@ import { LoginView } from "@bitwarden/common/models/view/loginView";
import { SecureNoteView } from "@bitwarden/common/models/view/secureNoteView";
@Directive()
export class AddEditComponent implements OnInit {
export class AddEditComponent implements OnInit, OnDestroy {
@Input() cloneMode = false;
@Input() folderId: string = null;
@Input() cipherId: string;
@@ -75,7 +75,9 @@ export class AddEditComponent implements OnInit {
reprompt = false;
canUseReprompt = true;
protected destroy$ = new Subject<void>();
protected writeableCollections: CollectionView[];
private personalOwnershipPolicyAppliesToActiveUser: boolean;
private previousCipherId: string;
constructor(
@@ -152,14 +154,28 @@ export class AddEditComponent implements OnInit {
}
async ngOnInit() {
await this.init();
this.policyService
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
.pipe(
concatMap(async (policyAppliesToActiveUser) => {
this.personalOwnershipPolicyAppliesToActiveUser = policyAppliesToActiveUser;
await this.init();
}),
takeUntil(this.destroy$)
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async init() {
if (this.ownershipOptions.length) {
this.ownershipOptions = [];
}
if (await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership)) {
if (this.personalOwnershipPolicyAppliesToActiveUser) {
this.allowPersonal = false;
} else {
const myEmail = await this.stateService.getEmail();

View File

@@ -1,4 +1,5 @@
import { Directive, OnInit } from "@angular/core";
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@@ -15,7 +16,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCry
import { PasswordColorText } from "../shared/components/password-strength/password-strength.component";
@Directive()
export class ChangePasswordComponent implements OnInit {
export class ChangePasswordComponent implements OnInit, OnDestroy {
masterPassword: string;
masterPasswordRetype: string;
formPromise: Promise<any>;
@@ -28,6 +29,8 @@ export class ChangePasswordComponent implements OnInit {
protected kdf: KdfType;
protected kdfIterations: number;
protected destroy$ = new Subject<void>();
constructor(
protected i18nService: I18nService,
protected cryptoService: CryptoService,
@@ -40,7 +43,18 @@ export class ChangePasswordComponent implements OnInit {
async ngOnInit() {
this.email = await this.stateService.getEmail();
this.enforcedPolicyOptions ??= await this.policyService.getMasterPasswordPolicyOptions();
this.policyService
.masterPasswordPolicyOptions$()
.pipe(takeUntil(this.destroy$))
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions ??= enforcedPasswordPolicyOptions)
);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async submit() {

View File

@@ -55,6 +55,13 @@ export class ExportComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this.disabledByPolicy = policyAppliesToActiveUser;
});
await this.checkExportDisabled();
merge(
@@ -71,9 +78,6 @@ export class ExportComponent implements OnInit, OnDestroy {
}
async checkExportDisabled() {
this.disabledByPolicy = await this.policyService.policyAppliesToUser(
PolicyType.DisablePersonalVaultExport
);
if (this.disabledByPolicy) {
this.exportForm.disable();
}
@@ -117,6 +121,7 @@ export class ExportComponent implements OnInit, OnDestroy {
await this.userVerificationService.verifyUser(secret);
} catch (e) {
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e.message);
return;
}
this.doExport();

View File

@@ -1,7 +1,7 @@
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subscription } from "rxjs";
import { take } from "rxjs/operators";
import { Subject } from "rxjs";
import { concatMap, take, takeUntil } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
@@ -41,7 +41,7 @@ export class LockComponent implements OnInit, OnDestroy {
private invalidPinAttempts = 0;
private pinSet: [boolean, boolean];
private activeAccountSubscription: Subscription;
private destroy$ = new Subject<void>();
constructor(
protected router: Router,
@@ -60,14 +60,19 @@ export class LockComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
// eslint-disable-next-line rxjs/no-async-subscribe
this.activeAccountSubscription = this.stateService.activeAccount$.subscribe(async () => {
await this.load();
});
this.stateService.activeAccount$
.pipe(
concatMap(async () => {
await this.load();
}),
takeUntil(this.destroy$)
)
.subscribe();
}
ngOnDestroy() {
this.activeAccountSubscription.unsubscribe();
this.destroy$.next();
this.destroy$.complete();
}
async submit() {

View File

@@ -65,7 +65,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
}
async ngOnInit() {
let email = this.formGroup.get("email")?.value;
let email = this.formGroup.value.email;
if (email == null || email === "") {
email = await this.stateService.getRememberedEmail();
this.formGroup.get("email")?.setValue(email);
@@ -81,9 +81,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
}
async submit(showToast = true) {
const email = this.formGroup.get("email")?.value;
const masterPassword = this.formGroup.get("masterPassword")?.value;
const rememberEmail = this.formGroup.get("rememberEmail")?.value;
const data = this.formGroup.value;
await this.setupCaptcha();
@@ -103,15 +101,15 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
try {
const credentials = new PasswordLogInCredentials(
email,
masterPassword,
data.email,
data.masterPassword,
this.captchaToken,
null
);
this.formPromise = this.authService.logIn(credentials);
const response = await this.formPromise;
if (rememberEmail || this.alwaysRememberEmail) {
await this.stateService.setRememberedEmail(email);
if (data.rememberEmail || this.alwaysRememberEmail) {
await this.stateService.setRememberedEmail(data.email);
} else {
await this.stateService.setRememberedEmail(null);
}
@@ -216,7 +214,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit
}
protected focusInput() {
const email = this.formGroup.get("email")?.value;
const email = this.formGroup.value.email;
document.getElementById(email == null || email === "" ? "email" : "masterPassword").focus();
}
}

View File

@@ -96,11 +96,11 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
}
async submit(showToast = true) {
let email = this.formGroup.get("email")?.value;
let email = this.formGroup.value.email;
email = email.trim().toLowerCase();
let name = this.formGroup.get("name")?.value;
let name = this.formGroup.value.name;
name = name === "" ? null : name; // Why do we do this?
const masterPassword = this.formGroup.get("masterPassword")?.value;
const masterPassword = this.formGroup.value.masterPassword;
try {
if (!this.accountCreated) {
const registerResponse = await this.registerAccount(
@@ -125,7 +125,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
if (loginResponse.captchaRequired) {
return;
}
this.createdAccount.emit(this.formGroup.get("email")?.value);
this.createdAccount.emit(this.formGroup.value.email);
} else {
this.platformUtilsService.showToast(
"success",
@@ -232,7 +232,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn
masterPassword: string,
name: string
): Promise<RegisterRequest> {
const hint = this.formGroup.get("hint")?.value;
const hint = this.formGroup.value.hint;
const kdf = DEFAULT_KDF_TYPE;
const kdfIterations = DEFAULT_KDF_ITERATIONS;
const key = await this.cryptoService.makeKey(masterPassword, email, kdf, kdfIterations);

View File

@@ -1,5 +1,6 @@
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@@ -18,7 +19,7 @@ import { SendTextView } from "@bitwarden/common/models/view/sendTextView";
import { SendView } from "@bitwarden/common/models/view/sendView";
@Directive()
export class AddEditComponent implements OnInit {
export class AddEditComponent implements OnInit, OnDestroy {
@Input() sendId: string;
@Input() type: SendType;
@@ -45,6 +46,7 @@ export class AddEditComponent implements OnInit {
showOptions = false;
private sendLinkBaseUrl: string;
private destroy$ = new Subject<void>();
constructor(
protected i18nService: I18nService,
@@ -80,9 +82,28 @@ export class AddEditComponent implements OnInit {
}
async ngOnInit() {
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisableSend)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this.disableSend = policyAppliesToActiveUser;
});
this.policyService
.policyAppliesToActiveUser$(PolicyType.SendOptions, (p) => p.data.disableHideEmail)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this.disableHideEmail = policyAppliesToActiveUser;
});
await this.load();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
get editMode(): boolean {
return this.sendId != null;
}
@@ -97,12 +118,6 @@ export class AddEditComponent implements OnInit {
}
async load() {
this.disableSend = await this.policyService.policyAppliesToUser(PolicyType.DisableSend);
this.disableHideEmail = await this.policyService.policyAppliesToUser(
PolicyType.SendOptions,
(p) => p.data.disableHideEmail
);
this.canAccessPremium = await this.stateService.getCanAccessPremium();
this.emailVerified = await this.stateService.getEmailVerified();
if (!this.canAccessPremium || !this.emailVerified) {

View File

@@ -1,4 +1,5 @@
import { Directive, NgZone, OnInit } from "@angular/core";
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@@ -12,7 +13,7 @@ import { SendType } from "@bitwarden/common/enums/sendType";
import { SendView } from "@bitwarden/common/models/view/sendView";
@Directive()
export class SendComponent implements OnInit {
export class SendComponent implements OnInit, OnDestroy {
disableSend = false;
sendType = SendType;
loaded = false;
@@ -36,6 +37,7 @@ export class SendComponent implements OnInit {
onSuccessfulLoad: () => Promise<any>;
private searchTimeout: any;
private destroy$ = new Subject<void>();
constructor(
protected sendService: SendService,
@@ -49,7 +51,17 @@ export class SendComponent implements OnInit {
) {}
async ngOnInit() {
this.disableSend = await this.policyService.policyAppliesToUser(PolicyType.DisableSend);
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisableSend)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this.disableSend = policyAppliesToActiveUser;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async load(filter: (send: SendView) => boolean = null) {

View File

@@ -1,11 +1,12 @@
import { Directive, Input, OnInit } from "@angular/core";
import { Directive, Input, OnDestroy, OnInit } from "@angular/core";
import {
AbstractControl,
ControlValueAccessor,
UntypedFormBuilder,
FormBuilder,
ValidationErrors,
Validator,
} from "@angular/forms";
import { combineLatestWith, Subject, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
@@ -13,7 +14,9 @@ import { PolicyType } from "@bitwarden/common/enums/policyType";
import { Policy } from "@bitwarden/common/models/domain/policy";
@Directive()
export class VaultTimeoutInputComponent implements ControlValueAccessor, Validator, OnInit {
export class VaultTimeoutInputComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy
{
get showCustom() {
return this.form.get("vaultTimeout").value === VaultTimeoutInputComponent.CUSTOM_VALUE;
}
@@ -42,29 +45,28 @@ export class VaultTimeoutInputComponent implements ControlValueAccessor, Validat
private onChange: (vaultTimeout: number) => void;
private validatorChange: () => void;
private destroy$ = new Subject<void>();
constructor(
private formBuilder: UntypedFormBuilder,
private formBuilder: FormBuilder,
private policyService: PolicyService,
private i18nService: I18nService
) {}
async ngOnInit() {
if (await this.policyService.policyAppliesToUser(PolicyType.MaximumVaultTimeout)) {
const vaultTimeoutPolicy = await this.policyService.getAll(PolicyType.MaximumVaultTimeout);
this.policyService
.policyAppliesToActiveUser$(PolicyType.MaximumVaultTimeout)
.pipe(combineLatestWith(this.policyService.policies$), takeUntil(this.destroy$))
.subscribe(([policyAppliesToActiveUser, policies]) => {
if (policyAppliesToActiveUser) {
const vaultTimeoutPolicy = policies.find(
(policy) => policy.type === PolicyType.MaximumVaultTimeout && policy.enabled
);
this.vaultTimeoutPolicy = vaultTimeoutPolicy[0];
this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60);
this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60;
this.vaultTimeouts = this.vaultTimeouts.filter(
(t) =>
t.value <= this.vaultTimeoutPolicy.data.minutes &&
(t.value > 0 || t.value === VaultTimeoutInputComponent.CUSTOM_VALUE) &&
t.value != null
);
this.validatorChange();
}
this.vaultTimeoutPolicy = vaultTimeoutPolicy;
this.applyVaultTimeoutPolicy();
}
});
// eslint-disable-next-line rxjs/no-async-subscribe
this.form.valueChanges.subscribe(async (value) => {
@@ -87,6 +89,11 @@ export class VaultTimeoutInputComponent implements ControlValueAccessor, Validat
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
ngOnChanges() {
this.vaultTimeouts.push({
name: this.i18nService.t("custom"),
@@ -152,6 +159,19 @@ export class VaultTimeoutInputComponent implements ControlValueAccessor, Validat
}
private customTimeInMinutes() {
return this.form.get("custom.hours")?.value * 60 + this.form.get("custom.minutes")?.value;
return this.form.value.custom.hours * 60 + this.form.value.custom.minutes;
}
private applyVaultTimeoutPolicy() {
this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60);
this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60;
this.vaultTimeouts = this.vaultTimeouts.filter(
(t) =>
t.value <= this.vaultTimeoutPolicy.data.minutes &&
(t.value > 0 || t.value === VaultTimeoutInputComponent.CUSTOM_VALUE) &&
t.value != null
);
this.validatorChange();
}
}

View File

@@ -60,7 +60,6 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent {
}
async setupSubmitActions(): Promise<boolean> {
this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions();
this.email = await this.stateService.getEmail();
this.kdf = await this.stateService.getKdfType();
this.kdfIterations = await this.stateService.getKdfIterations();

View File

@@ -1,10 +1,9 @@
import { Directive, ElementRef, Input, OnChanges } from "@angular/core";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { ErrorResponse } from "@bitwarden/common/models/response/errorResponse";
import { ValidationService } from "../services/validation.service";
/**
* Provides error handling, in particular for any error returned by the server in an api call.
* Attach it to a <form> element and provide the name of the class property that will hold the api call promise.

View File

@@ -55,6 +55,7 @@ import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/comm
import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction";
import { UsernameGenerationService as UsernameGenerationServiceAbstraction } from "@bitwarden/common/abstractions/usernameGeneration.service";
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/abstractions/validation.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
@@ -102,6 +103,7 @@ import { TwoFactorService } from "@bitwarden/common/services/twoFactor.service";
import { UserVerificationApiService } from "@bitwarden/common/services/userVerification/userVerification-api.service";
import { UserVerificationService } from "@bitwarden/common/services/userVerification/userVerification.service";
import { UsernameGenerationService } from "@bitwarden/common/services/usernameGeneration.service";
import { ValidationService } from "@bitwarden/common/services/validation.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vaultTimeout/vaultTimeout.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vaultTimeout/vaultTimeoutSettings.service";
import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service";
@@ -127,12 +129,10 @@ import { ModalService } from "./modal.service";
import { PasswordRepromptService } from "./passwordReprompt.service";
import { ThemingService } from "./theming/theming.service";
import { AbstractThemingService } from "./theming/theming.service.abstraction";
import { ValidationService } from "./validation.service";
@NgModule({
declarations: [],
providers: [
ValidationService,
AuthGuard,
UnauthGuard,
LockGuard,
@@ -561,6 +561,11 @@ import { ValidationService } from "./validation.service";
useClass: AnonymousHubService,
deps: [EnvironmentServiceAbstraction, AuthServiceAbstraction, LogService],
},
{
provide: ValidationServiceAbstraction,
useClass: ValidationService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction],
},
],
})
export class JslibServicesModule {}

View File

@@ -49,6 +49,11 @@ export class ModalService {
return this.modalList[this.modalCount - 1];
}
/**
* @deprecated Use `dialogService.open` (in web) or `modalService.open` (in desktop/browser) instead.
* If replacing an existing call to this method, also remove any `@ViewChild` and `<ng-template>` associated with the
* existing usage.
*/
async openViewRef<T>(
componentType: Type<T>,
viewContainerRef: ViewContainerRef,

View File

@@ -1,7 +1,6 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, mergeMap, Observable, from } from "rxjs";
import { firstValueFrom, from, mergeMap, Observable } from "rxjs";
import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/abstractions/deprecated-vault-filter.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
@@ -15,6 +14,7 @@ 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 { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "../../../abstractions/deprecated-vault-filter.service";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
const NestingDelimiter = "/";
@@ -84,11 +84,15 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
}
async checkForSingleOrganizationPolicy(): Promise<boolean> {
return await this.policyService.policyAppliesToUser(PolicyType.SingleOrg);
return await firstValueFrom(
this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg)
);
}
async checkForPersonalOwnershipPolicy(): Promise<boolean> {
return await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership);
return await firstValueFrom(
this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
);
}
protected async getAllFoldersNested(folders: FolderView[]): Promise<TreeNode<FolderView>[]> {

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@bitwarden/common/abstractions/api.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@bitwarden/common/abstractions/api.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@bitwarden/common/abstractions/api.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@bitwarden/common/abstractions/api.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { Jsonify } from "type-fest";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { mock, MockProxy } from "jest-mock-extended";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { UriMatchType } from "@bitwarden/common/enums/uriMatchType";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg, SubstituteOf } from "@fluffy-spoon/substitute";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { SendType } from "@bitwarden/common/enums/sendType";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@bitwarden/common/abstractions/api.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject } from "rxjs";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject, firstValueFrom } from "rxjs";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "@bitwarden/common/abstractions/api.service";

View File

@@ -0,0 +1,357 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
import { PolicyType } from "@bitwarden/common/enums/policyType";
import { PermissionsApi } from "@bitwarden/common/models/api/permissionsApi";
import { OrganizationData } from "@bitwarden/common/models/data/organizationData";
import { PolicyData } from "@bitwarden/common/models/data/policyData";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/models/domain/masterPasswordPolicyOptions";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { Policy } from "@bitwarden/common/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/models/domain/resetPasswordPolicyOptions";
import { ListResponse } from "@bitwarden/common/models/response/listResponse";
import { PolicyResponse } from "@bitwarden/common/models/response/policyResponse";
import { ContainerService } from "@bitwarden/common/services/container.service";
import { EncryptService } from "@bitwarden/common/services/encrypt.service";
import { PolicyService } from "@bitwarden/common/services/policy/policy.service";
import { StateService } from "@bitwarden/common/services/state.service";
describe("PolicyService", () => {
let policyService: PolicyService;
let cryptoService: SubstituteOf<CryptoService>;
let stateService: SubstituteOf<StateService>;
let organizationService: SubstituteOf<OrganizationService>;
let encryptService: SubstituteOf<EncryptService>;
let activeAccount: BehaviorSubject<string>;
let activeAccountUnlocked: BehaviorSubject<boolean>;
beforeEach(() => {
stateService = Substitute.for();
organizationService = Substitute.for();
organizationService
.getAll("user")
.resolves([
new Organization(
organizationData(
"test-organization",
true,
true,
OrganizationUserStatusType.Accepted,
false
)
),
]);
organizationService.getAll(undefined).resolves([]);
organizationService.getAll(null).resolves([]);
activeAccount = new BehaviorSubject("123");
activeAccountUnlocked = new BehaviorSubject(true);
stateService.getEncryptedPolicies().resolves({
"1": policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, {
minutes: 14,
}),
});
stateService.activeAccount$.returns(activeAccount);
stateService.activeAccountUnlocked$.returns(activeAccountUnlocked);
stateService.getUserId().resolves("user");
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
policyService = new PolicyService(stateService, organizationService);
});
afterEach(() => {
activeAccount.complete();
activeAccountUnlocked.complete();
});
it("upsert", async () => {
await policyService.upsert(policyData("99", "test-organization", PolicyType.DisableSend, true));
expect(await firstValueFrom(policyService.policies$)).toEqual([
{
id: "1",
organizationId: "test-organization",
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
},
{
id: "99",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
},
]);
});
it("replace", async () => {
await policyService.replace({
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
});
expect(await firstValueFrom(policyService.policies$)).toEqual([
{
id: "2",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
},
]);
});
it("locking should clear", async () => {
activeAccountUnlocked.next(false);
// Sleep for 100ms to avoid timing issues
await new Promise((r) => setTimeout(r, 100));
expect((await firstValueFrom(policyService.policies$)).length).toBe(0);
});
describe("clear", () => {
it("null userId", async () => {
await policyService.clear();
stateService.received(1).setEncryptedPolicies(Arg.any(), Arg.any());
expect((await firstValueFrom(policyService.policies$)).length).toBe(0);
});
it("matching userId", async () => {
await policyService.clear("user");
stateService.received(1).setEncryptedPolicies(Arg.any(), Arg.any());
expect((await firstValueFrom(policyService.policies$)).length).toBe(0);
});
it("mismatching userId", async () => {
await policyService.clear("12");
stateService.received(1).setEncryptedPolicies(Arg.any(), Arg.any());
expect((await firstValueFrom(policyService.policies$)).length).toBe(1);
});
});
describe("masterPasswordPolicyOptions", () => {
it("returns default policy options", async () => {
const data: any = {
minComplexity: 5,
minLength: 20,
requireUpper: true,
};
const model = [
new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual({
minComplexity: 5,
minLength: 20,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
requireUpper: true,
});
});
it("returns null", async () => {
const data: any = {};
const model = [
new Policy(
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data)
),
new Policy(
policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data)
),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual(null);
});
it("returns specified policy options", async () => {
const data: any = {
minLength: 14,
};
const model = [
new Policy(
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data)
),
new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual({
minComplexity: 0,
minLength: 14,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
requireUpper: false,
});
});
});
describe("evaluateMasterPassword", () => {
it("false", async () => {
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
enforcedPolicyOptions.minLength = 14;
const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions);
expect(result).toEqual(false);
});
it("true", async () => {
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions);
expect(result).toEqual(true);
});
});
describe("getResetPasswordPolicyOptions", () => {
it("default", async () => {
const result = policyService.getResetPasswordPolicyOptions(null, null);
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
});
it("returns autoEnrollEnabled true", async () => {
const data: any = {
autoEnrollEnabled: true,
};
const policies = [
new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)),
];
const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3");
expect(result).toEqual([{ autoEnrollEnabled: true }, true]);
});
});
describe("mapPoliciesFromToken", () => {
it("null", async () => {
const result = policyService.mapPoliciesFromToken(null);
expect(result).toEqual(null);
});
it("null data", async () => {
const model = new ListResponse(null, PolicyResponse);
model.data = null;
const result = policyService.mapPoliciesFromToken(model);
expect(result).toEqual(null);
});
it("empty array", async () => {
const model = new ListResponse(null, PolicyResponse);
const result = policyService.mapPoliciesFromToken(model);
expect(result).toEqual([]);
});
it("success", async () => {
const policyResponse: any = {
Data: [
{
Id: "1",
OrganizationId: "organization-1",
Type: PolicyType.DisablePersonalVaultExport,
Enabled: true,
Data: { requireUpper: true },
},
{
Id: "2",
OrganizationId: "organization-2",
Type: PolicyType.DisableSend,
Enabled: false,
Data: { minComplexity: 5, minLength: 20 },
},
],
};
const model = new ListResponse(policyResponse, PolicyResponse);
const result = policyService.mapPoliciesFromToken(model);
expect(result).toEqual([
new Policy(
policyData("1", "organization-1", PolicyType.DisablePersonalVaultExport, true, {
requireUpper: true,
})
),
new Policy(
policyData("2", "organization-2", PolicyType.DisableSend, false, {
minComplexity: 5,
minLength: 20,
})
),
]);
});
});
describe("policyAppliesToActiveUser", () => {
it("MasterPassword does not apply", async () => {
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.MasterPassword)
);
expect(result).toEqual(false);
});
it("MaximumVaultTimeout applies", async () => {
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.MaximumVaultTimeout)
);
expect(result).toEqual(true);
});
it("DisablePersonalVaultExport does not apply", async () => {
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport)
);
expect(result).toEqual(false);
});
});
function policyData(
id: string,
organizationId: string,
type: PolicyType,
enabled: boolean,
data?: any
) {
const policyData = new PolicyData({} as any);
policyData.id = id;
policyData.organizationId = organizationId;
policyData.type = type;
policyData.enabled = enabled;
policyData.data = data;
return policyData;
}
function organizationData(
id: string,
enabled: boolean,
usePolicies: boolean,
status: OrganizationUserStatusType,
managePolicies: boolean
) {
const organizationData = new OrganizationData({} as any);
organizationData.id = id;
organizationData.enabled = enabled;
organizationData.usePolicies = usePolicies;
organizationData.status = status;
organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any);
return organizationData;
}
});

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject, firstValueFrom } from "rxjs";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { EncString } from "@bitwarden/common/models/domain/encString";

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute } from "@fluffy-spoon/substitute";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";

View File

@@ -1,6 +1,5 @@
import { PolicyType } from "../../enums/policyType";
import { MasterPasswordPolicyOptions } from "../../models/domain/masterPasswordPolicyOptions";
import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policyRequest";
import { ListResponse } from "../../models/response/listResponse";
import { PolicyResponse } from "../../models/response/policyResponse";
@@ -18,7 +17,6 @@ export class PolicyApiServiceAbstraction {
organizationId: string,
userId: string
) => Promise<ListResponse<PolicyResponse>>;
getPolicyForOrganization: (policyType: PolicyType, organizationId: string) => Promise<Policy>;
getMasterPasswordPoliciesForInvitedUsers: (orgId: string) => Promise<MasterPasswordPolicyOptions>;
putPolicy: (organizationId: string, type: PolicyType, request: PolicyRequest) => Promise<any>;
}

View File

@@ -1,3 +1,5 @@
import { Observable } from "rxjs";
import { PolicyType } from "../../enums/policyType";
import { PolicyData } from "../../models/data/policyData";
import { MasterPasswordPolicyOptions } from "../../models/domain/masterPasswordPolicyOptions";
@@ -7,9 +9,17 @@ import { ListResponse } from "../../models/response/listResponse";
import { PolicyResponse } from "../../models/response/policyResponse";
export abstract class PolicyService {
getAll: (type?: PolicyType, userId?: string) => Promise<Policy[]>;
policies$: Observable<Policy[]>;
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
policyAppliesToActiveUser$: (
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean
) => Observable<boolean>;
getMasterPasswordPolicyOptions: (policies?: Policy[]) => Promise<MasterPasswordPolicyOptions>;
/**
* @deprecated Do not call this, use the policies$ observable collection
*/
getAll: (type?: PolicyType, userId?: string) => Promise<Policy[]>;
evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
@@ -29,6 +39,6 @@ export abstract class PolicyService {
export abstract class InternalPolicyService extends PolicyService {
upsert: (policy: PolicyData) => Promise<any>;
replace: (policies: { [id: string]: PolicyData }) => Promise<any>;
replace: (policies: { [id: string]: PolicyData }) => Promise<void>;
clear: (userId?: string) => Promise<any>;
}

View File

@@ -103,7 +103,13 @@ export abstract class StateService<T extends Account = Account> {
) => Promise<void>;
getDecryptedPinProtected: (options?: StorageOptions) => Promise<EncString>;
setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this, use PolicyService
*/
getDecryptedPolicies: (options?: StorageOptions) => Promise<Policy[]>;
/**
* @deprecated Do not call this, use PolicyService
*/
setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise<void>;
getDecryptedPrivateKey: (options?: StorageOptions) => Promise<ArrayBuffer>;
setDecryptedPrivateKey: (value: ArrayBuffer, options?: StorageOptions) => Promise<void>;
@@ -214,7 +220,13 @@ export abstract class StateService<T extends Account = Account> {
) => Promise<void>;
getEncryptedPinProtected: (options?: StorageOptions) => Promise<string>;
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use PolicyService
*/
getEncryptedPolicies: (options?: StorageOptions) => Promise<{ [id: string]: PolicyData }>;
/**
* @deprecated Do not call this directly, use PolicyService
*/
setEncryptedPolicies: (
value: { [id: string]: PolicyData },
options?: StorageOptions

View File

@@ -0,0 +1,3 @@
export abstract class ValidationService {
showError: (data: any) => string[];
}

View File

@@ -339,6 +339,12 @@ export class Utils {
return str == null || typeof str !== "string" || str == "";
}
static isPromise(obj: any): obj is Promise<unknown> {
return (
obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function"
);
}
static nameOf<T>(name: string & keyof T) {
return name;
}

View File

@@ -1,24 +0,0 @@
import { EventService } from "../abstractions/event.service";
import { EventType } from "../enums/eventType";
/**
* If you want to use this, don't.
* If you think you should use that after the warning, don't.
*/
export class NoopEventService implements EventService {
constructor() {
if (chrome.runtime.getManifest().manifest_version !== 3) {
throw new Error("You are not allowed to use this when not in manifest_version 3");
}
}
collect(eventType: EventType, cipherId?: string, uploadImmediately?: boolean) {
return Promise.resolve();
}
uploadEvents(userId?: string) {
return Promise.resolve();
}
clearEvents(userId?: string) {
return Promise.resolve();
}
}

View File

@@ -1,3 +1,4 @@
import { firstValueFrom, map } from "rxjs";
import * as zxcvbn from "zxcvbn";
import { CryptoService } from "../abstractions/crypto.service";
@@ -258,7 +259,11 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
const policies: Policy[] =
this.policyService == null
? null
: await this.policyService.getAll(PolicyType.PasswordGenerator);
: await firstValueFrom(
this.policyService.policies$.pipe(
map((p) => p.filter((policy) => policy.type === PolicyType.PasswordGenerator))
)
);
let enforcedOptions: PasswordGeneratorPolicyOptions = null;
if (policies == null || policies.length === 0) {

View File

@@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "../../abstractions/policy/policy-api.service.abstraction";
@@ -6,7 +8,6 @@ import { StateService } from "../../abstractions/state.service";
import { PolicyType } from "../../enums/policyType";
import { PolicyData } from "../../models/data/policyData";
import { MasterPasswordPolicyOptions } from "../../models/domain/masterPasswordPolicyOptions";
import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policyRequest";
import { ListResponse } from "../../models/response/listResponse";
import { PolicyResponse } from "../../models/response/policyResponse";
@@ -79,30 +80,13 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
return new ListResponse(r, PolicyResponse);
}
async getPolicyForOrganization(policyType: PolicyType, organizationId: string): Promise<Policy> {
const org = await this.organizationService.get(organizationId);
if (org?.isProviderUser) {
const orgPolicies = await this.getPolicies(organizationId);
const policy = orgPolicies.data.find((p) => p.organizationId === organizationId);
if (policy == null) {
return null;
}
return new Policy(new PolicyData(policy));
}
const policies = await this.policyService.getAll(policyType);
return policies.find((p) => p.organizationId === organizationId);
}
async getMasterPasswordPoliciesForInvitedUsers(
orgId: string
): Promise<MasterPasswordPolicyOptions> {
const userId = await this.stateService.getUserId();
const response = await this.getPoliciesByInvitedUser(orgId, userId);
const policies = await this.policyService.mapPoliciesFromToken(response);
return this.policyService.getMasterPasswordPolicyOptions(policies);
return await firstValueFrom(this.policyService.masterPasswordPolicyOptions$(policies));
}
async putPolicy(organizationId: string, type: PolicyType, request: PolicyRequest): Promise<any> {

View File

@@ -1,9 +1,12 @@
import { of, concatMap, BehaviorSubject, Observable, map } from "rxjs";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction";
import { StateService } from "../../abstractions/state.service";
import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType";
import { OrganizationUserType } from "../../enums/organizationUserType";
import { PolicyType } from "../../enums/policyType";
import { Utils } from "../../misc/utils";
import { PolicyData } from "../../models/data/policyData";
import { MasterPasswordPolicyOptions } from "../../models/domain/masterPasswordPolicyOptions";
import { Organization } from "../../models/domain/organization";
@@ -13,13 +16,37 @@ import { ListResponse } from "../../models/response/listResponse";
import { PolicyResponse } from "../../models/response/policyResponse";
export class PolicyService implements InternalPolicyServiceAbstraction {
policyCache: Policy[];
private _policies: BehaviorSubject<Policy[]> = new BehaviorSubject([]);
policies$ = this._policies.asObservable();
constructor(
private stateService: StateService,
private organizationService: OrganizationService
) {}
) {
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (Utils.global.bitwardenContainerService == null) {
return;
}
if (!unlocked) {
this._policies.next([]);
return;
}
const data = await this.stateService.getEncryptedPolicies();
await this.updateObservables(data);
})
)
.subscribe();
}
/**
* @deprecated Do not call this, use the policies$ observable collection
*/
async getAll(type?: PolicyType, userId?: string): Promise<Policy[]> {
let response: Policy[] = [];
const decryptedPolicies = await this.stateService.getDecryptedPolicies({ userId: userId });
@@ -28,8 +55,7 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
} else {
const diskPolicies = await this.stateService.getEncryptedPolicies({ userId: userId });
for (const id in diskPolicies) {
// eslint-disable-next-line
if (diskPolicies.hasOwnProperty(id)) {
if (Object.prototype.hasOwnProperty.call(diskPolicies, id)) {
response.push(new Policy(diskPolicies[id]));
}
}
@@ -42,60 +68,72 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
}
}
async getMasterPasswordPolicyOptions(policies?: Policy[]): Promise<MasterPasswordPolicyOptions> {
let enforcedOptions: MasterPasswordPolicyOptions = null;
masterPasswordPolicyOptions$(policies?: Policy[]): Observable<MasterPasswordPolicyOptions> {
const observable = policies ? of(policies) : this.policies$;
return observable.pipe(
map((obsPolicies) => {
let enforcedOptions: MasterPasswordPolicyOptions = null;
const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword);
if (policies == null) {
policies = await this.getAll(PolicyType.MasterPassword);
} else {
policies = policies.filter((p) => p.type === PolicyType.MasterPassword);
}
if (filteredPolicies == null || filteredPolicies.length === 0) {
return enforcedOptions;
}
if (policies == null || policies.length === 0) {
return enforcedOptions;
}
filteredPolicies.forEach((currentPolicy) => {
if (!currentPolicy.enabled || currentPolicy.data == null) {
return;
}
policies.forEach((currentPolicy) => {
if (!currentPolicy.enabled || currentPolicy.data == null) {
return;
}
if (enforcedOptions == null) {
enforcedOptions = new MasterPasswordPolicyOptions();
}
if (enforcedOptions == null) {
enforcedOptions = new MasterPasswordPolicyOptions();
}
if (
currentPolicy.data.minComplexity != null &&
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
) {
enforcedOptions.minComplexity = currentPolicy.data.minComplexity;
}
if (
currentPolicy.data.minComplexity != null &&
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
) {
enforcedOptions.minComplexity = currentPolicy.data.minComplexity;
}
if (
currentPolicy.data.minLength != null &&
currentPolicy.data.minLength > enforcedOptions.minLength
) {
enforcedOptions.minLength = currentPolicy.data.minLength;
}
if (
currentPolicy.data.minLength != null &&
currentPolicy.data.minLength > enforcedOptions.minLength
) {
enforcedOptions.minLength = currentPolicy.data.minLength;
}
if (currentPolicy.data.requireUpper) {
enforcedOptions.requireUpper = true;
}
if (currentPolicy.data.requireUpper) {
enforcedOptions.requireUpper = true;
}
if (currentPolicy.data.requireLower) {
enforcedOptions.requireLower = true;
}
if (currentPolicy.data.requireLower) {
enforcedOptions.requireLower = true;
}
if (currentPolicy.data.requireNumbers) {
enforcedOptions.requireNumbers = true;
}
if (currentPolicy.data.requireNumbers) {
enforcedOptions.requireNumbers = true;
}
if (currentPolicy.data.requireSpecial) {
enforcedOptions.requireSpecial = true;
}
});
if (currentPolicy.data.requireSpecial) {
enforcedOptions.requireSpecial = true;
}
});
return enforcedOptions;
})
);
}
return enforcedOptions;
policyAppliesToActiveUser$(
policyType: PolicyType,
policyFilter: (policy: Policy) => boolean = (p) => true
) {
return this.policies$.pipe(
concatMap(async (policies) => {
const userId = await this.stateService.getUserId();
return await this.checkPoliciesThatApplyToUser(policies, policyType, policyFilter, userId);
})
);
}
evaluateMasterPassword(
@@ -174,25 +212,8 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
userId?: string
) {
const policies = await this.getAll(policyType, userId);
const organizations = await this.organizationService.getAll(userId);
let filteredPolicies;
if (policyFilter != null) {
filteredPolicies = policies.filter((p) => p.enabled && policyFilter(p));
} else {
filteredPolicies = policies.filter((p) => p.enabled);
}
const policySet = new Set(filteredPolicies.map((p) => p.organizationId));
return organizations.some(
(o) =>
o.enabled &&
o.status >= OrganizationUserStatusType.Accepted &&
o.usePolicies &&
!this.isExcemptFromPolicies(o, policyType) &&
policySet.has(o.id)
);
return this.checkPoliciesThatApplyToUser(policies, policyType, policyFilter, userId);
}
async upsert(policy: PolicyData): Promise<any> {
@@ -203,17 +224,19 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
policies[policy.id] = policy;
await this.stateService.setDecryptedPolicies(null);
await this.updateObservables(policies);
await this.stateService.setEncryptedPolicies(policies);
}
async replace(policies: { [id: string]: PolicyData }): Promise<any> {
await this.stateService.setDecryptedPolicies(null);
async replace(policies: { [id: string]: PolicyData }): Promise<void> {
await this.updateObservables(policies);
await this.stateService.setEncryptedPolicies(policies);
}
async clear(userId?: string): Promise<any> {
await this.stateService.setDecryptedPolicies(null, { userId: userId });
async clear(userId?: string): Promise<void> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._policies.next([]);
}
await this.stateService.setEncryptedPolicies(null, { userId: userId });
}
@@ -224,4 +247,32 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
return organization.isExemptFromPolicies;
}
private async updateObservables(policiesMap: { [id: string]: PolicyData }) {
const policies = Object.values(policiesMap || {}).map((f) => new Policy(f));
this._policies.next(policies);
}
private async checkPoliciesThatApplyToUser(
policies: Policy[],
policyType: PolicyType,
policyFilter: (policy: Policy) => boolean = (p) => true,
userId?: string
) {
const organizations = await this.organizationService.getAll(userId);
const filteredPolicies = policies.filter(
(p) => p.type === policyType && p.enabled && policyFilter(p)
);
const policySet = new Set(filteredPolicies.map((p) => p.organizationId));
return organizations.some(
(o) =>
o.enabled &&
o.status >= OrganizationUserStatusType.Accepted &&
o.usePolicies &&
policySet.has(o.id) &&
!this.isExcemptFromPolicies(o, policyType)
);
}
}

View File

@@ -1,11 +1,9 @@
import { Injectable } from "@angular/core";
import { I18nService } from "../abstractions/i18n.service";
import { PlatformUtilsService } from "../abstractions/platformUtils.service";
import { ValidationService as ValidationServiceAbstraction } from "../abstractions/validation.service";
import { ErrorResponse } from "../models/response/errorResponse";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ErrorResponse } from "@bitwarden/common/models/response/errorResponse";
@Injectable()
export class ValidationService {
export class ValidationService implements ValidationServiceAbstraction {
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService

View File

@@ -0,0 +1,14 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../shared";
import { BitActionDirective } from "./bit-action.directive";
import { BitSubmitDirective } from "./bit-submit.directive";
import { BitFormButtonDirective } from "./form-button.directive";
@NgModule({
imports: [SharedModule],
declarations: [BitActionDirective, BitFormButtonDirective, BitSubmitDirective],
exports: [BitActionDirective, BitFormButtonDirective, BitSubmitDirective],
})
export class AsyncActionsModule {}

View File

@@ -0,0 +1,58 @@
import { Directive, HostListener, Input, OnDestroy, Optional } from "@angular/core";
import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable";
/**
* Allow a single button to perform async actions on click and reflect the progress in the UI by automatically
* activating the loading effect while the action is processed.
*/
@Directive({
selector: "[bitAction]",
})
export class BitActionDirective implements OnDestroy {
private destroy$ = new Subject<void>();
private _loading$ = new BehaviorSubject<boolean>(false);
@Input("bitAction") protected handler: FunctionReturningAwaitable;
readonly loading$ = this._loading$.asObservable();
constructor(
private buttonComponent: ButtonLikeAbstraction,
@Optional() private validationService?: ValidationService
) {}
get loading() {
return this._loading$.value;
}
set loading(value: boolean) {
this._loading$.next(value);
this.buttonComponent.loading = value;
}
@HostListener("click")
protected async onClick() {
if (!this.handler) {
return;
}
this.loading = true;
functionToObservable(this.handler)
.pipe(
tap({ error: (err: unknown) => this.validationService?.showError(err) }),
finalize(() => (this.loading = false)),
takeUntil(this.destroy$)
)
.subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,82 @@
import { Directive, Input, OnDestroy, OnInit, Optional } from "@angular/core";
import { FormGroupDirective } from "@angular/forms";
import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable";
/**
* Allow a form to perform async actions on submit, disabling the form while the action is processing.
*/
@Directive({
selector: "[formGroup][bitSubmit]",
})
export class BitSubmitDirective implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private _loading$ = new BehaviorSubject<boolean>(false);
private _disabled$ = new BehaviorSubject<boolean>(false);
@Input("bitSubmit") protected handler: FunctionReturningAwaitable;
readonly loading$ = this._loading$.asObservable();
readonly disabled$ = this._disabled$.asObservable();
constructor(
private formGroupDirective: FormGroupDirective,
@Optional() validationService?: ValidationService
) {
formGroupDirective.ngSubmit
.pipe(
filter(() => !this.disabled),
switchMap(() => {
// Calling functionToObservable exectues the sync part of the handler
// allowing the function to check form validity before it gets disabled.
const awaitable = functionToObservable(this.handler);
// Disable form
this.loading = true;
return awaitable.pipe(
catchError((err: unknown) => {
validationService?.showError(err);
return of(undefined);
})
);
}),
takeUntil(this.destroy$)
)
.subscribe({
next: () => (this.loading = false),
complete: () => (this.loading = false),
});
}
ngOnInit(): void {
this.formGroupDirective.statusChanges
.pipe(takeUntil(this.destroy$))
.subscribe((c) => this._disabled$.next(c === "DISABLED"));
}
get disabled() {
return this._disabled$.value;
}
set disabled(value: boolean) {
this._disabled$.next(value);
}
get loading() {
return this._loading$.value;
}
set loading(value: boolean) {
this.disabled = value;
this._loading$.next(value);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,58 @@
import { Directive, Input, OnDestroy, Optional } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { BitSubmitDirective } from "./bit-submit.directive";
import { BitActionDirective } from ".";
/**
* This directive has two purposes:
*
* When attached to a submit button:
* - Activates the button loading effect while the form is processing an async submit action.
* - Disables the button while a `bitAction` directive on another button is being processed.
*
* When attached to a standalone button with `bitAction` directive:
* - Disables the form while the `bitAction` directive is processing an async submit action.
*/
@Directive({
selector: "button[bitFormButton]",
})
export class BitFormButtonDirective implements OnDestroy {
private destroy$ = new Subject<void>();
@Input() type: string;
constructor(
buttonComponent: ButtonLikeAbstraction,
@Optional() submitDirective?: BitSubmitDirective,
@Optional() actionDirective?: BitActionDirective
) {
if (submitDirective && buttonComponent) {
submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
if (this.type === "submit") {
buttonComponent.loading = loading;
} else {
buttonComponent.disabled = loading;
}
});
submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
buttonComponent.disabled = disabled;
});
}
if (submitDirective && actionDirective) {
actionDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
submitDirective.disabled = disabled;
});
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,114 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Component Library/Async Actions/In Forms/Documentation" />
# Async Actions In Forms
These directives should be used when building forms with buttons that trigger long running tasks in the background,
eg. Submit or Delete buttons. For buttons that are not associated with a form see [Standalone Async Actions](?path=/story/component-library-async-actions-standalone-documentation--page).
There are two separately supported use-cases: Submit buttons and standalone form buttons (eg. Delete buttons).
## Usage: Submit buttons
Adding async actions to submit buttons requires the following 3 steps
### 1. Add a handler to your `Component`
A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is
useful for aborting an action.
**NOTE:**
- Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent
component using the variable `this`.
- `formGroup.invalid` will always return `true` after the first `await` operation, event if the form is not actually
invalid. This is due to the form getting disabled by the `bitSubmit` directive while waiting for the async action to complete.
```ts
@Component({...})
class Component {
formGroup = this.formBuilder.group({...});
// submit can also return Observable instead of Promise
submit = async () => {
if (this.formGroup.invalid) {
return;
}
await this.cryptoService.encrypt(/* ... */);
// `formGroup.invalid` will always return `true` here
await this.apiService.post(/* ... */);
}
}
```
### 2. Add directive to the `form` element
Add the `bitSubmit` directive and supply the handler defined in step 1.
**NOTE:** The `directive` is defined using the input syntax: `[input]="handler"`.
This is different from how submit handlers are usually defined with the output syntax `(ngSubmit)="handler()"`.
```html
<form [formGroup]="formGroup" [bitSubmit]="submit">...</form>
```
### 3. Add directive to the `type="submit"` button
Add both `bitButton` and `bitFormButton` directives to the button.
```html
<button type="submit" bitButton bitFormButton>{{ "submit" | i18n }}</button>
```
## Usage: Standalone form buttons
Adding async actions to standalone form buttons requires the following 3 steps.
### 1. Add a handler to your `Component`
A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is
useful for aborting an action.
**NOTE:** Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent
component using the variable `this`.
```ts
@Component({...})
class Component {
formGroup = this.formBuilder.group({...});
submit = async () => {
// not relevant for this example
}
// action can also return Observable instead of Promise
handler = async () => {
if (/* perform guard check */) {
return;
}
await this.apiService.post(/* ... */);
};
}
```
### 2. Add directive to the `form` element
The `bitSubmit` directive is required beacuse of its coordinating role.
```html
<form [formGroup]="formGroup" [bitSubmit]="submit">...</form>
```
### 3. Add directives to the `button` element
Add `bitButton`, `bitFormButton`, `bitAction` directives to the button. Make sure to supply a handler.
```html
<button type="button" bitFormButton bitButton [bitAction]="handler">Do action</button>
<button type="button" bitFormButton bitIconButton="bwi-star" [bitAction]="handler"></button>
```

View File

@@ -0,0 +1,149 @@
import { Component } from "@angular/core";
import { FormsModule, ReactiveFormsModule, Validators, FormBuilder } from "@angular/forms";
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { delay, of } from "rxjs";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { I18nService } from "@bitwarden/common/src/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { FormFieldModule } from "../form-field";
import { IconButtonModule } from "../icon-button";
import { InputModule } from "../input/input.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BitActionDirective } from "./bit-action.directive";
import { BitSubmitDirective } from "./bit-submit.directive";
import { BitFormButtonDirective } from "./form-button.directive";
const template = `
<form [formGroup]="formObj" [bitSubmit]="submit">
<bit-form-field>
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>Email</bit-label>
<input bitInput formControlName="email" />
</bit-form-field>
<button class="tw-mr-2" type="submit" buttonType="primary" bitButton bitFormButton>Submit</button>
<button class="tw-mr-2" type="button" buttonType="secondary" bitButton bitFormButton>Cancel</button>
<button class="tw-mr-2" type="button" buttonType="danger" bitButton bitFormButton [bitAction]="delete">Delete</button>
<button class="tw-mr-2" type="button" buttonType="secondary" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
</form>`;
@Component({
selector: "app-promise-example",
template,
})
class PromiseExampleComponent {
formObj = this.formBuilder.group({
name: ["", [Validators.required]],
email: ["", [Validators.required, Validators.email]],
});
constructor(private formBuilder: FormBuilder) {}
submit = async () => {
this.formObj.markAllAsTouched();
if (!this.formObj.valid) {
return;
}
await new Promise<void>((resolve, reject) => {
setTimeout(resolve, 2000);
});
};
delete = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(resolve, 2000);
});
};
}
@Component({
selector: "app-observable-example",
template,
})
class ObservableExampleComponent {
formObj = this.formBuilder.group({
name: ["", [Validators.required]],
email: ["", [Validators.required, Validators.email]],
});
constructor(private formBuilder: FormBuilder) {}
submit = () => {
this.formObj.markAllAsTouched();
if (!this.formObj.valid) {
return undefined;
}
return of("fake observable").pipe(delay(2000));
};
delete = () => {
return of("fake observable").pipe(delay(2000));
};
}
export default {
title: "Component Library/Async Actions/In Forms",
decorators: [
moduleMetadata({
declarations: [
BitSubmitDirective,
BitFormButtonDirective,
PromiseExampleComponent,
ObservableExampleComponent,
BitActionDirective,
],
imports: [
FormsModule,
ReactiveFormsModule,
FormFieldModule,
InputModule,
ButtonModule,
IconButtonModule,
],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
required: "required",
inputRequired: "Input is required.",
inputEmail: "Input is not an email-address.",
});
},
},
{
provide: ValidationService,
useValue: {
showError: action("ValidationService.showError"),
} as Partial<ValidationService>,
},
],
}),
],
} as Meta;
const PromiseTemplate: Story<PromiseExampleComponent> = (args: PromiseExampleComponent) => ({
props: args,
template: `<app-promise-example></app-promise-example>`,
});
export const UsingPromise = PromiseTemplate.bind({});
const ObservableTemplate: Story<PromiseExampleComponent> = (args: PromiseExampleComponent) => ({
props: args,
template: `<app-observable-example></app-observable-example>`,
});
export const UsingObservable = ObservableTemplate.bind({});

View File

@@ -0,0 +1,3 @@
export * from "./async-actions.module";
export * from "./bit-action.directive";
export * from "./form-button.directive";

View File

@@ -0,0 +1,26 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Component Library/Async Actions/Overview" />
# Async Actions
The directives in this module makes it easier for developers to reflect the progress of async actions in the UI when using
buttons, while also providing robust and standardized error handling.
These buttons can either be standalone (such as Refresh buttons), submit buttons for forms or as standalone buttons
that are part of a form (such as Delete buttons).
These directives are meant to replace the older `appApiAction` directive, providing the option to use `observables` and reduce
clutter inside our view `components`.
## When to use?
When building a button that triggers a long running task in the background eg. server API calls.
## Why?
To better visualize that the application is processing their request.
## What does it do?
It disables buttons and show a spinning animation.

View File

@@ -0,0 +1,63 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Component Library/Async Actions/Standalone/Documentation" />
# Standalone Async Actions
These directives should be used when building a standalone button that triggers a long running task in the background,
eg. Refresh buttons. For non-submit buttons that are associated with forms see [Async Actions In Forms](?path=/story/component-library-async-actions-in-forms-documentation--page).
## Usage
Adding async actions to standalone buttons requires the following 2 steps
### 1. Add a handler to your `Component`
A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is
useful for aborting an action.
**NOTE:** Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent
component using the variable `this`.
#### Example using promises
```ts
@Component({...})
class PromiseExampleComponent {
handler = async () => {
if (/* perform guard check */) {
return;
}
await this.apiService.post(/* ... */);
};
}
```
#### Example using observables
```ts
@Component({...})
class Component {
handler = () => {
if (/* perform guard check */) {
return;
}
return this.apiService.post$(/* ... */);
};
}
```
### 2. Add directive to the DOM element
Add the `bitAction` directive and supply the handler defined in step 1.
**NOTE:** The `directive` is defined using the input syntax: `[input]="handler"`.
This is different from how click handlers are usually defined with the output syntax `(click)="handler()"`.
```html
<button bitButton [bitAction]="handler">Do action</button>
<button bitIconButton="bwi-trash" [bitAction]="handler"></button>`;
```

View File

@@ -0,0 +1,97 @@
import { Component } from "@angular/core";
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { delay, of } from "rxjs";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { BitActionDirective } from "./bit-action.directive";
const template = `
<button bitButton buttonType="primary" [bitAction]="action" class="tw-mr-2">
Perform action
</button>
<button bitIconButton="bwi-trash" buttonType="danger" [bitAction]="action"></button>`;
@Component({
template,
selector: "app-promise-example",
})
class PromiseExampleComponent {
action = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(resolve, 2000);
});
};
}
@Component({
template,
selector: "app-observable-example",
})
class ObservableExampleComponent {
action = () => {
return of("fake observable").pipe(delay(2000));
};
}
@Component({
template,
selector: "app-rejected-promise-example",
})
class RejectedPromiseExampleComponent {
action = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(() => reject(new Error("Simulated error")), 2000);
});
};
}
export default {
title: "Component Library/Async Actions/Standalone",
decorators: [
moduleMetadata({
declarations: [
BitActionDirective,
PromiseExampleComponent,
ObservableExampleComponent,
RejectedPromiseExampleComponent,
],
imports: [ButtonModule, IconButtonModule],
providers: [
{
provide: ValidationService,
useValue: {
showError: action("ValidationService.showError"),
} as Partial<ValidationService>,
},
],
}),
],
} as Meta;
const PromiseTemplate: Story<PromiseExampleComponent> = (args: PromiseExampleComponent) => ({
props: args,
template: `<app-promise-example></app-promise-example>`,
});
export const UsingPromise = PromiseTemplate.bind({});
const ObservableTemplate: Story<ObservableExampleComponent> = (
args: ObservableExampleComponent
) => ({
template: `<app-observable-example></app-observable-example>`,
});
export const UsingObservable = ObservableTemplate.bind({});
const RejectedPromiseTemplate: Story<ObservableExampleComponent> = (
args: ObservableExampleComponent
) => ({
template: `<app-rejected-promise-example></app-rejected-promise-example>`,
});
export const RejectedPromise = RejectedPromiseTemplate.bind({});

View File

@@ -2,7 +2,10 @@
<span [ngClass]="{ 'tw-invisible': loading }">
<ng-content></ng-content>
</span>
<span class="tw-absolute tw-inset-0" [ngClass]="{ 'tw-invisible': !loading }">
<i class="bwi bwi-spinner bwi-lg bwi-spin tw-align-baseline" aria-hidden="true"></i>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</span>
</span>

View File

@@ -1,5 +1,7 @@
import { Input, HostBinding, Component } from "@angular/core";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
export type ButtonTypes = "primary" | "secondary" | "danger";
const buttonStyles: Record<ButtonTypes, string[]> = {
@@ -41,8 +43,9 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
@Component({
selector: "button[bitButton], a[bitButton]",
templateUrl: "button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
})
export class ButtonComponent {
export class ButtonComponent implements ButtonLikeAbstraction {
@HostBinding("class") get classList() {
return [
"tw-font-semibold",

View File

@@ -0,0 +1,8 @@
export abstract class BitFormFieldControl {
ariaDescribedBy: string;
id: string;
labelForId: string;
required: boolean;
hasError: boolean;
error: [string, any];
}

View File

@@ -1,4 +1,4 @@
<label class="tw-mb-1 tw-block tw-font-semibold tw-text-main" [attr.for]="input.id">
<label class="tw-mb-1 tw-block tw-font-semibold tw-text-main" [attr.for]="input.labelForId">
<ng-content select="bit-label"></ng-content>
<span *ngIf="input.required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
</label>

View File

@@ -7,9 +7,8 @@ import {
ViewChild,
} from "@angular/core";
import { BitInputDirective } from "../input/input.directive";
import { BitErrorComponent } from "./error.component";
import { BitFormFieldControl } from "./form-field-control";
import { BitHintComponent } from "./hint.component";
import { BitPrefixDirective } from "./prefix.directive";
import { BitSuffixDirective } from "./suffix.directive";
@@ -22,7 +21,7 @@ import { BitSuffixDirective } from "./suffix.directive";
},
})
export class BitFormFieldComponent implements AfterContentChecked {
@ContentChild(BitInputDirective) input: BitInputDirective;
@ContentChild(BitFormFieldControl) input: BitFormFieldControl;
@ContentChild(BitHintComponent) hint: BitHintComponent;
@ViewChild(BitErrorComponent) error: BitErrorComponent;

View File

@@ -2,6 +2,8 @@ import { NgModule } from "@angular/core";
import { BitInputDirective } from "../input/input.directive";
import { InputModule } from "../input/input.module";
import { MultiSelectComponent } from "../multi-select/multi-select.component";
import { MultiSelectModule } from "../multi-select/multi-select.module";
import { SharedModule } from "../shared";
import { BitErrorSummary } from "./error-summary.component";
@@ -13,16 +15,17 @@ import { BitPrefixDirective } from "./prefix.directive";
import { BitSuffixDirective } from "./suffix.directive";
@NgModule({
imports: [SharedModule, InputModule],
imports: [SharedModule, InputModule, MultiSelectModule],
exports: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
BitHintComponent,
BitInputDirective,
BitLabel,
BitPrefixDirective,
BitSuffixDirective,
BitInputDirective,
MultiSelectComponent,
],
declarations: [
BitErrorComponent,

View File

@@ -1,2 +1,3 @@
export * from "./form-field.module";
export * from "./form-field.component";
export * from "./form-field-control";

View File

@@ -0,0 +1,283 @@
import {
FormsModule,
ReactiveFormsModule,
FormBuilder,
Validators,
FormGroup,
} from "@angular/forms";
import { NgSelectModule } from "@ng-select/ng-select";
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { BadgeModule } from "../badge";
import { ButtonModule } from "../button";
import { InputModule } from "../input/input.module";
import { MultiSelectComponent } from "../multi-select/multi-select.component";
import { SharedModule } from "../shared";
import { I18nMockService } from "../utils/i18n-mock.service";
import { FormFieldModule } from "./form-field.module";
export default {
title: "Component Library/Form/Multi Select",
excludeStories: /.*Data$/,
component: MultiSelectComponent,
decorators: [
moduleMetadata({
imports: [
ButtonModule,
FormsModule,
NgSelectModule,
FormFieldModule,
InputModule,
ReactiveFormsModule,
BadgeModule,
SharedModule,
],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
multiSelectPlaceholder: "-- Type to Filter --",
multiSelectLoading: "Retrieving options...",
multiSelectNotFound: "No items found",
multiSelectClearAll: "Clear all",
required: "required",
inputRequired: "Input is required.",
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689",
},
},
} as Meta;
export const actionsData = {
onItemsConfirmed: action("onItemsConfirmed"),
};
const fb = new FormBuilder();
const formObjFactory = () =>
fb.group({
select: [[], [Validators.required]],
});
function submit(formObj: FormGroup) {
formObj.markAllAsTouched();
}
const MultiSelectTemplate: Story<MultiSelectComponent> = (args: MultiSelectComponent) => ({
props: {
formObj: formObjFactory(),
submit: submit,
...args,
onItemsConfirmed: actionsData.onItemsConfirmed,
},
template: `
<form [formGroup]="formObj" (ngSubmit)="submit(formObj)">
<bit-form-field>
<bit-label>{{ name }}</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="select"
[baseItems]="baseItems"
[removeSelectedItems]="removeSelectedItems"
[loading]="loading"
[disabled]="disabled"
(onItemsConfirmed)="onItemsConfirmed($event)">
</bit-multi-select>
<bit-hint>{{ hint }}</bit-hint>
</bit-form-field>
<button type="submit" bitButton buttonType="primary">Submit</button>
</form>
`,
});
export const Loading = MultiSelectTemplate.bind({});
Loading.args = {
baseItems: [],
name: "Loading",
hint: "This is what a loading multi-select looks like",
loading: "true",
};
export const Disabled = MultiSelectTemplate.bind({});
Disabled.args = {
name: "Disabled",
disabled: "true",
hint: "This is what a disabled multi-select looks like",
};
export const Groups = MultiSelectTemplate.bind({});
Groups.args = {
name: "Select groups",
hint: "Groups will be assigned to the associated member",
baseItems: [
{ id: "1", listName: "Group 1", labelName: "Group 1", icon: "bwi-family" },
{ id: "2", listName: "Group 2", labelName: "Group 2", icon: "bwi-family" },
{ id: "3", listName: "Group 3", labelName: "Group 3", icon: "bwi-family" },
{ id: "4", listName: "Group 4", labelName: "Group 4", icon: "bwi-family" },
{ id: "5", listName: "Group 5", labelName: "Group 5", icon: "bwi-family" },
{ id: "6", listName: "Group 6", labelName: "Group 6", icon: "bwi-family" },
{ id: "7", listName: "Group 7", labelName: "Group 7", icon: "bwi-family" },
],
};
export const Members = MultiSelectTemplate.bind({});
Members.args = {
name: "Select members",
hint: "Members will be assigned to the associated group/collection",
baseItems: [
{ id: "1", listName: "Joe Smith (jsmith@mail.me)", labelName: "Joe Smith", icon: "bwi-user" },
{
id: "2",
listName: "Tania Stone (tstone@mail.me)",
labelName: "Tania Stone",
icon: "bwi-user",
},
{
id: "3",
listName: "Matt Matters (mmatters@mail.me)",
labelName: "Matt Matters",
icon: "bwi-user",
},
{
id: "4",
listName: "Bob Robertson (brobertson@mail.me)",
labelName: "Bob Robertson",
icon: "bwi-user",
},
{
id: "5",
listName: "Ashley Fletcher (aflectcher@mail.me)",
labelName: "Ashley Fletcher",
icon: "bwi-user",
},
{ id: "6", listName: "Rita Olson (rolson@mail.me)", labelName: "Rita Olson", icon: "bwi-user" },
{
id: "7",
listName: "Final listName (fname@mail.me)",
labelName: "(fname@mail.me)",
icon: "bwi-user",
},
],
};
export const Collections = MultiSelectTemplate.bind({});
Collections.args = {
name: "Select collections",
hint: "Collections will be assigned to the associated member",
baseItems: [
{ id: "1", listName: "Collection 1", labelName: "Collection 1", icon: "bwi-collection" },
{ id: "2", listName: "Collection 2", labelName: "Collection 2", icon: "bwi-collection" },
{ id: "3", listName: "Collection 3", labelName: "Collection 3", icon: "bwi-collection" },
{
id: "3.5",
listName: "Child Collection 1 for Parent 1",
labelName: "Child Collection 1 for Parent 1",
icon: "bwi-collection",
parentGrouping: "Parent 1",
},
{
id: "3.55",
listName: "Child Collection 2 for Parent 1",
labelName: "Child Collection 2 for Parent 1",
icon: "bwi-collection",
parentGrouping: "Parent 1",
},
{
id: "3.59",
listName: "Child Collection 3 for Parent 1",
labelName: "Child Collection 3 for Parent 1",
icon: "bwi-collection",
parentGrouping: "Parent 1",
},
{
id: "3.75",
listName: "Child Collection 1 for Parent 2",
labelName: "Child Collection 1 for Parent 2",
icon: "bwi-collection",
parentGrouping: "Parent 2",
},
{ id: "4", listName: "Collection 4", labelName: "Collection 4", icon: "bwi-collection" },
{ id: "5", listName: "Collection 5", labelName: "Collection 5", icon: "bwi-collection" },
{ id: "6", listName: "Collection 6", labelName: "Collection 6", icon: "bwi-collection" },
{ id: "7", listName: "Collection 7", labelName: "Collection 7", icon: "bwi-collection" },
],
};
export const MembersAndGroups = MultiSelectTemplate.bind({});
MembersAndGroups.args = {
name: "Select groups and members",
hint: "Members/Groups will be assigned to the associated collection",
baseItems: [
{ id: "1", listName: "Group 1", labelName: "Group 1", icon: "bwi-family" },
{ id: "2", listName: "Group 2", labelName: "Group 2", icon: "bwi-family" },
{ id: "3", listName: "Group 3", labelName: "Group 3", icon: "bwi-family" },
{ id: "4", listName: "Group 4", labelName: "Group 4", icon: "bwi-family" },
{ id: "5", listName: "Group 5", labelName: "Group 5", icon: "bwi-family" },
{ id: "6", listName: "Joe Smith (jsmith@mail.me)", labelName: "Joe Smith", icon: "bwi-user" },
{
id: "7",
listName: "Tania Stone (tstone@mail.me)",
labelName: "(tstone@mail.me)",
icon: "bwi-user",
},
],
};
export const RemoveSelected = MultiSelectTemplate.bind({});
RemoveSelected.args = {
name: "Select groups",
hint: "Groups will be removed from the list once the dropdown is closed",
baseItems: [
{ id: "1", listName: "Group 1", labelName: "Group 1", icon: "bwi-family" },
{ id: "2", listName: "Group 2", labelName: "Group 2", icon: "bwi-family" },
{ id: "3", listName: "Group 3", labelName: "Group 3", icon: "bwi-family" },
{ id: "4", listName: "Group 4", labelName: "Group 4", icon: "bwi-family" },
{ id: "5", listName: "Group 5", labelName: "Group 5", icon: "bwi-family" },
{ id: "6", listName: "Group 6", labelName: "Group 6", icon: "bwi-family" },
{ id: "7", listName: "Group 7", labelName: "Group 7", icon: "bwi-family" },
],
removeSelectedItems: "true",
};
const StandaloneTemplate: Story<MultiSelectComponent> = (args: MultiSelectComponent) => ({
props: {
...args,
onItemsConfirmed: actionsData.onItemsConfirmed,
},
template: `
<bit-multi-select
class="tw-w-full"
[baseItems]="baseItems"
[removeSelectedItems]="removeSelectedItems"
[loading]="loading"
[disabled]="disabled"
(onItemsConfirmed)="onItemsConfirmed($event)">
</bit-multi-select>
`,
});
export const Standalone = StandaloneTemplate.bind({});
Standalone.args = {
baseItems: [
{ id: "1", listName: "Group 1", labelName: "Group 1", icon: "bwi-family" },
{ id: "2", listName: "Group 2", labelName: "Group 2", icon: "bwi-family" },
{ id: "3", listName: "Group 3", labelName: "Group 3", icon: "bwi-family" },
{ id: "4", listName: "Group 4", labelName: "Group 4", icon: "bwi-family" },
{ id: "5", listName: "Group 5", labelName: "Group 5", icon: "bwi-family" },
{ id: "6", listName: "Group 6", labelName: "Group 6", icon: "bwi-family" },
{ id: "7", listName: "Group 7", labelName: "Group 7", icon: "bwi-family" },
],
removeSelectedItems: "true",
};

View File

@@ -0,0 +1,15 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
>
<i
class="bwi bwi-spinner bwi-spin"
aria-hidden="true"
[ngClass]="{ 'bwi-lg': size === 'default' }"
></i>
</span>
</span>

View File

@@ -1,8 +1,10 @@
import { Component, HostBinding, Input } from "@angular/core";
export type IconButtonStyle = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
const styles: Record<IconButtonStyle, string[]> = {
export type IconButtonType = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger";
const styles: Record<IconButtonType, string[]> = {
contrast: [
"tw-bg-transparent",
"!tw-text-contrast",
@@ -10,6 +12,7 @@ const styles: Record<IconButtonStyle, string[]> = {
"hover:tw-bg-transparent-hover",
"hover:tw-border-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
],
main: [
@@ -19,6 +22,7 @@ const styles: Record<IconButtonStyle, string[]> = {
"hover:tw-bg-transparent-hover",
"hover:tw-border-text-main",
"focus-visible:before:tw-ring-text-main",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
],
muted: [
@@ -28,6 +32,7 @@ const styles: Record<IconButtonStyle, string[]> = {
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
],
primary: [
@@ -37,6 +42,7 @@ const styles: Record<IconButtonStyle, string[]> = {
"hover:tw-bg-primary-700",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-border-primary-500",
"disabled:hover:tw-bg-primary-500",
],
secondary: [
@@ -46,6 +52,7 @@ const styles: Record<IconButtonStyle, string[]> = {
"hover:!tw-text-contrast",
"hover:tw-bg-text-muted",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-border-text-muted",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-muted",
"disabled:hover:tw-border-text-muted",
@@ -57,6 +64,7 @@ const styles: Record<IconButtonStyle, string[]> = {
"hover:!tw-text-contrast",
"hover:tw-bg-danger-500",
"focus-visible:before:tw-ring-primary-700",
"disabled:hover:tw-border-danger-500",
"disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-danger",
"disabled:hover:tw-border-danger-500",
@@ -72,12 +80,13 @@ const sizes: Record<IconButtonSize, string[]> = {
@Component({
selector: "button[bitIconButton]",
template: `<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>`,
templateUrl: "icon-button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
})
export class BitIconButtonComponent {
export class BitIconButtonComponent implements ButtonLikeAbstraction {
@Input("bitIconButton") icon: string;
@Input() buttonType: IconButtonStyle = "main";
@Input() buttonType: IconButtonType = "main";
@Input() size: IconButtonSize = "default";
@@ -90,7 +99,6 @@ export class BitIconButtonComponent {
"tw-transition",
"hover:tw-no-underline",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent",
"focus:tw-outline-none",
// Workaround for box-shadow with transparent offset issue:
@@ -117,4 +125,13 @@ export class BitIconButtonComponent {
get iconClass() {
return [this.icon, "!tw-m-0"];
}
@HostBinding("attr.disabled")
get disabledAttr() {
const disabled = this.disabled != null && this.disabled !== false;
return disabled || this.loading ? true : null;
}
@Input() loading = false;
@Input() disabled = false;
}

View File

@@ -1,59 +1,91 @@
import { Meta, Story } from "@storybook/angular";
import { BitIconButtonComponent } from "./icon-button.component";
import { BitIconButtonComponent, IconButtonType } from "./icon-button.component";
const buttonTypes: IconButtonType[] = [
"contrast",
"main",
"muted",
"primary",
"secondary",
"danger",
];
export default {
title: "Component Library/Icon Button",
component: BitIconButtonComponent,
args: {
bitIconButton: "bwi-plus",
buttonType: "primary",
size: "default",
disabled: false,
},
argTypes: {
buttonTypes: { table: { disable: true } },
},
} as Meta;
const Template: Story<BitIconButtonComponent> = (args: BitIconButtonComponent) => ({
props: args,
props: { ...args, buttonTypes },
template: `
<div class="tw-p-5" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
[disabled]="disabled"
title="Example icon button"
aria-label="Example icon button"></button>
</div>
<table class="tw-border-spacing-2 tw-text-center tw-text-main">
<thead>
<tr>
<td></td>
<td *ngFor="let buttonType of buttonTypes" class="tw-capitalize tw-font-bold tw-p-4"
[class.tw-text-contrast]="buttonType === 'contrast'"
[class.tw-bg-primary-500]="buttonType === 'contrast'">{{buttonType}}</td>
</tr>
</thead>
<tbody>
<tr>
<td class="tw-font-bold tw-p-4 tw-text-left">Default</td>
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
title="Example icon button"
aria-label="Example icon button"></button>
</td>
</tr>
<tr>
<td class="tw-font-bold tw-p-4 tw-text-left">Disabled</td>
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
disabled
title="Example icon button"
aria-label="Example icon button"></button>
</td>
</tr>
<tr>
<td class="tw-font-bold tw-p-4 tw-text-left">Loading</td>
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
loading="true"
title="Example icon button"
aria-label="Example icon button"></button>
</td>
</tr>
</tbody>
</table>
`,
});
export const Contrast = Template.bind({});
Contrast.args = {
buttonType: "contrast",
export const Default = Template.bind({});
Default.args = {
size: "default",
};
export const Main = Template.bind({});
Main.args = {
buttonType: "main",
};
export const Muted = Template.bind({});
Muted.args = {
buttonType: "muted",
};
export const Primary = Template.bind({});
Primary.args = {
buttonType: "primary",
};
export const Secondary = Template.bind({});
Secondary.args = {
buttonType: "secondary",
};
export const Danger = Template.bind({});
Danger.args = {
buttonType: "danger",
export const Small = Template.bind({});
Small.args = {
size: "small",
};

View File

@@ -1,3 +1,4 @@
export * from "./async-actions";
export * from "./badge";
export * from "./banner";
export * from "./button";

View File

@@ -1,13 +1,16 @@
import { Directive, HostBinding, Input, Optional, Self } from "@angular/core";
import { NgControl, Validators } from "@angular/forms";
import { BitFormFieldControl } from "../form-field/form-field-control";
// Increments for each instance of this component
let nextId = 0;
@Directive({
selector: "input[bitInput], select[bitInput], textarea[bitInput]",
providers: [{ provide: BitFormFieldControl, useExisting: BitInputDirective }],
})
export class BitInputDirective {
export class BitInputDirective implements BitFormFieldControl {
@HostBinding("class") @Input() get classList() {
return [
"tw-block",
@@ -38,6 +41,10 @@ export class BitInputDirective {
@HostBinding("attr.aria-describedby") ariaDescribedBy: string;
get labelForId(): string {
return this.id;
}
@HostBinding("attr.aria-invalid") get ariaInvalid() {
return this.hasError ? true : undefined;
}

View File

@@ -0,0 +1,7 @@
export type SelectItemView = {
id: string; // Unique ID used for comparisons
listName: string; // Default bindValue -> this is what will be displayed in list items
labelName: string; // This is what will be displayed in the selection option badge
icon: string; // Icon to display within the list
parentGrouping: string; // Used to group items by parent
};

View File

@@ -0,0 +1,55 @@
<ng-select
[items]="baseItems"
[(ngModel)]="selectedItems"
(ngModelChange)="onChange($event)"
(blur)="onBlur()"
bindLabel="listName"
groupBy="parentGrouping"
[placeholder]="placeholder"
[loading]="loading"
[loadingText]="loadingText"
notFoundText="{{ 'multiSelectNotFound' | i18n }}"
clearAllText="{{ 'multiSelectClearAll' | i18n }}"
[multiple]="true"
[selectOnTab]="true"
[closeOnSelect]="false"
(close)="onDropdownClosed()"
[disabled]="disabled"
[clearSearchOnAdd]="true"
[labelForId]="labelForId"
>
<ng-template ng-loadingspinner-tmp>
<i class="bwi bwi-spinner bwi-spin tw-mr-1" [title]="loadingText" aria-hidden="true"></i>
</ng-template>
<ng-template ng-label-tmp let-item="item" let-clear="clear">
<button
type="button"
bitBadge
badgeType="primary"
class="tw-mr-1 disabled:tw-border-0"
[disabled]="disabled"
(click)="clear(item)"
>
<i
*ngIf="item.icon != null"
class="tw-mr-1 bwi bwi-fw {{ item.icon }}"
aria-hidden="true"
></i>
{{ item.labelName }}
<i class="bwi bwi-fw bwi-close bwi-sm tw-ml-1" aria-hidden="true"></i>
</button>
</ng-template>
<ng-template ng-option-tmp let-item="item">
<div class="tw-flex">
<div class="tw-w-7 tw-flex-none">
<i *ngIf="isSelected(item)" class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
</div>
<div class="tw-mr-2 tw-flex-initial">
<i *ngIf="item.icon != null" class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
</div>
<div class="tw-flex-1">
{{ item.listName }}
</div>
</div>
</ng-template>
</ng-select>

View File

@@ -0,0 +1,179 @@
import {
Component,
Input,
OnInit,
Output,
ViewChild,
EventEmitter,
HostBinding,
Optional,
Self,
} from "@angular/core";
import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
import { NgSelectComponent } from "@ng-select/ng-select";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { BitFormFieldControl } from "../form-field/form-field-control";
import { SelectItemView } from "./models/select-item-view";
// Increments for each instance of this component
let nextId = 0;
@Component({
selector: "bit-multi-select",
templateUrl: "./multi-select.component.html",
providers: [{ provide: BitFormFieldControl, useExisting: MultiSelectComponent }],
})
/**
* This component has been implemented to only support Multi-select list events
*/
export class MultiSelectComponent implements OnInit, BitFormFieldControl, ControlValueAccessor {
@ViewChild(NgSelectComponent) select: NgSelectComponent;
// Parent component should only pass selectable items (complete list - selected items = baseItems)
@Input() baseItems: SelectItemView[];
// Defaults to native ng-select behavior - set to "true" to clear selected items on dropdown close
@Input() removeSelectedItems = false;
@Input() placeholder: string;
@Input() loading = false;
@Input() disabled = false;
// Internal tracking of selected items
@Input() selectedItems: SelectItemView[];
// Default values for our implementation
loadingText: string;
protected searchInputId = `search-input-${nextId++}`;
/**Implemented as part of NG_VALUE_ACCESSOR */
private notifyOnChange?: (value: SelectItemView[]) => void;
/**Implemented as part of NG_VALUE_ACCESSOR */
private notifyOnTouched?: () => void;
@Output() onItemsConfirmed = new EventEmitter<any[]>();
constructor(private i18nService: I18nService, @Optional() @Self() private ngControl?: NgControl) {
if (ngControl != null) {
ngControl.valueAccessor = this;
}
}
ngOnInit(): void {
// Default Text Values
this.placeholder = this.placeholder ?? this.i18nService.t("multiSelectPlaceholder");
this.loadingText = this.i18nService.t("multiSelectLoading");
}
/** Helper method for showing selected state in custom template */
isSelected(item: any): boolean {
return this.selectedItems?.find((selected) => selected.id === item.id) != undefined;
}
/**
* The `close` callback will act as the only trigger for signifying the user's intent of completing the selection
* of items. Selected items will be emitted to the parent component in order to allow for separate data handling.
*/
onDropdownClosed(): void {
// Early exit
if (this.selectedItems == null || this.selectedItems.length == 0) {
return;
}
// Emit results to parent component
this.onItemsConfirmed.emit(this.selectedItems);
// Remove selected items from base list based on input property
if (this.removeSelectedItems) {
let updatedBaseItems = this.baseItems;
this.selectedItems.forEach((selectedItem) => {
updatedBaseItems = updatedBaseItems.filter((item) => selectedItem.id !== item.id);
});
// Reset Lists
this.selectedItems = null;
this.baseItems = updatedBaseItems;
}
}
/**Implemented as part of NG_VALUE_ACCESSOR */
writeValue(obj: SelectItemView[]): void {
this.selectedItems = obj;
}
/**Implemented as part of NG_VALUE_ACCESSOR */
registerOnChange(fn: (value: SelectItemView[]) => void): void {
this.notifyOnChange = fn;
}
/**Implemented as part of NG_VALUE_ACCESSOR */
registerOnTouched(fn: any): void {
this.notifyOnTouched = fn;
}
/**Implemented as part of NG_VALUE_ACCESSOR */
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/**Implemented as part of NG_VALUE_ACCESSOR */
protected onChange(items: SelectItemView[]) {
if (!this.notifyOnChange) {
return;
}
this.notifyOnChange(items);
}
/**Implemented as part of NG_VALUE_ACCESSOR */
protected onBlur() {
if (!this.notifyOnTouched) {
return;
}
this.notifyOnTouched();
}
/**Implemented as part of BitFormFieldControl */
@HostBinding("attr.aria-describedby")
get ariaDescribedBy() {
return this._ariaDescribedBy;
}
set ariaDescribedBy(value: string) {
this._ariaDescribedBy = value;
this.select?.searchInput.nativeElement.setAttribute("aria-describedby", value);
}
private _ariaDescribedBy: string;
/**Implemented as part of BitFormFieldControl */
get labelForId() {
return this.searchInputId;
}
/**Implemented as part of BitFormFieldControl */
@HostBinding() @Input() id = `bit-multi-select-${nextId++}`;
/**Implemented as part of BitFormFieldControl */
@HostBinding("attr.required")
@Input()
get required() {
return this._required ?? this.ngControl?.control?.hasValidator(Validators.required) ?? false;
}
set required(value: any) {
this._required = value != null && value !== false;
}
private _required: boolean;
/**Implemented as part of BitFormFieldControl */
get hasError() {
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
}
/**Implemented as part of BitFormFieldControl */
get error(): [string, any] {
const key = Object.keys(this.ngControl?.errors)[0];
return [key, this.ngControl?.errors[key]];
}
}

View File

@@ -0,0 +1,16 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { NgSelectModule } from "@ng-select/ng-select";
import { BadgeModule } from "../badge";
import { SharedModule } from "../shared";
import { MultiSelectComponent } from "./multi-select.component";
@NgModule({
imports: [CommonModule, FormsModule, NgSelectModule, BadgeModule, SharedModule],
exports: [MultiSelectComponent],
declarations: [MultiSelectComponent],
})
export class MultiSelectModule {}

View File

@@ -0,0 +1,394 @@
// Default theme copied from https://github.com/ng-select/ng-select/blob/master/src/ng-select/themes/default.theme.scss
@mixin rtl {
@at-root [dir="rtl"] #{&} {
@content;
}
}
$ng-select-highlight: rgb(var(--color-primary-700)) !default;
$ng-select-primary-text: rgb(var(--color-text-main)) !default;
$ng-select-disabled-text: rgb(var(--color-secondary-100)) !default;
$ng-select-border: rgb(var(--color-secondary-500)) !default;
$ng-select-border-radius: 4px !default;
$ng-select-bg: rgb(var(--color-background-alt)) !default;
$ng-select-selected: transparent !default;
$ng-select-selected-text: $ng-select-primary-text !default;
$ng-select-marked: rgb(var(--color-text-main) / 0.12) !default;
$ng-select-marked-text: $ng-select-primary-text !default;
$ng-select-box-shadow: none !default;
$ng-select-placeholder: rgb(var(--color-text-muted)) !default;
$ng-select-height: 36px !default;
$ng-select-value-padding-left: 10px !default;
$ng-select-value-font-size: 0.9em !default;
$ng-select-value-text: $ng-select-primary-text !default;
$ng-select-dropdown-bg: $ng-select-bg !default;
$ng-select-dropdown-border: $ng-select-border !default;
$ng-select-dropdown-optgroup-text: rgb(var(--color-text-muted)) !default;
$ng-select-dropdown-optgroup-marked: $ng-select-dropdown-optgroup-text !default;
$ng-select-dropdown-option-bg: $ng-select-dropdown-bg !default;
$ng-select-dropdown-option-text: $ng-select-primary-text !default;
$ng-select-dropdown-option-disabled: rgb(var(--color-text-muted) / 0.6) !default;
$ng-select-input-text: $ng-select-primary-text !default;
// Custom color variables
$ng-select-arrow-hover: rgb(var(--color-secondary-700)) !default;
$ng-clear-icon-hover: rgb(var(--color-text-main)) !default;
$ng-dropdown-shadow: rgb(var(--color-secondary-100)) !default;
.ng-select {
&.ng-select-opened {
> .ng-select-container {
background: $ng-select-bg;
border-color: $ng-select-border;
&:hover {
box-shadow: none;
}
.ng-arrow {
top: -2px;
border-color: transparent transparent $ng-select-arrow-hover;
border-width: 0 5px 5px;
&:hover {
border-color: transparent transparent $ng-select-arrow-hover;
}
}
}
&.ng-select-top {
> .ng-select-container {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
}
&.ng-select-right {
> .ng-select-container {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&.ng-select-bottom {
> .ng-select-container {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
&.ng-select-left {
> .ng-select-container {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
&.ng-select-focused {
&:not(.ng-select-opened) > .ng-select-container {
border-color: $ng-select-highlight;
box-shadow: $ng-select-box-shadow;
}
}
&.ng-select-disabled {
> .ng-select-container {
background-color: $ng-select-disabled-text;
}
}
.ng-has-value .ng-placeholder {
display: none;
}
.ng-select-container {
color: $ng-select-primary-text;
background-color: $ng-select-bg;
border-radius: $ng-select-border-radius;
border: 1px solid $ng-select-border;
min-height: $ng-select-height;
align-items: center;
&:hover {
box-shadow: 0 1px 0 $ng-dropdown-shadow;
}
.ng-value-container {
align-items: center;
padding-left: $ng-select-value-padding-left;
@include rtl {
padding-right: $ng-select-value-padding-left;
padding-left: 0;
}
.ng-placeholder {
color: $ng-select-placeholder;
}
}
}
&.ng-select-single {
.ng-select-container {
height: $ng-select-height;
.ng-value-container {
.ng-input {
top: 5px;
left: 0;
padding-left: $ng-select-value-padding-left;
padding-right: 50px;
@include rtl {
padding-right: $ng-select-value-padding-left;
padding-left: 50px;
}
}
}
}
}
&.ng-select-multiple {
&.ng-select-disabled {
> .ng-select-container .ng-value-container .ng-value {
background-color: $ng-select-disabled-text;
border: 0px solid $ng-select-border; // Removing border on slected value when disabled
.ng-value-label {
padding: 0 5px;
}
}
}
.ng-select-container {
.ng-value-container {
padding-top: 5px;
padding-left: 7px;
@include rtl {
padding-right: 7px;
padding-left: 0;
}
.ng-value {
font-size: $ng-select-value-font-size;
margin-bottom: 5px;
color: $ng-select-value-text;
background-color: $ng-select-selected;
border-radius: 2px;
margin-right: 5px;
@include rtl {
margin-right: 0;
margin-left: 5px;
}
&.ng-value-disabled {
background-color: $ng-select-disabled-text;
.ng-value-label {
padding-left: 5px;
@include rtl {
padding-left: 0;
padding-right: 5px;
}
}
}
.ng-value-label {
display: inline-block;
padding: 1px 5px;
}
.ng-value-icon {
display: inline-block;
padding: 1px 5px;
&:hover {
background-color: $ng-select-arrow-hover;
}
&.left {
border-right: 1px solid $ng-select-selected;
@include rtl {
border-left: 1px solid $ng-select-selected;
border-right: none;
}
}
&.right {
border-left: 1px solid $ng-select-selected;
@include rtl {
border-left: 0;
border-right: 1px solid $ng-select-selected;
}
}
}
}
.ng-input {
padding: 0 0 3px 3px;
@include rtl {
padding: 0 3px 3px 0;
}
> input {
color: $ng-select-input-text;
}
}
.ng-placeholder {
top: 5px;
padding-bottom: 5px;
padding-left: 3px;
@include rtl {
padding-right: 3px;
padding-left: 0;
}
}
}
}
}
.ng-clear-wrapper {
color: $ng-select-placeholder;
padding-top: 2.5px;
&:hover .ng-clear {
color: $ng-clear-icon-hover;
}
}
.ng-spinner-zone {
padding: 5px 5px 0 0;
@include rtl {
padding: 5px 0 0 5px;
}
}
.ng-arrow-wrapper {
width: 25px;
padding-right: 5px;
@include rtl {
padding-left: 5px;
padding-right: 0;
}
&:hover {
.ng-arrow {
border-top-color: $ng-select-arrow-hover;
}
}
.ng-arrow {
border-color: $ng-select-placeholder transparent transparent;
border-style: solid;
border-width: 5px 5px 2.5px;
}
}
}
.ng-dropdown-panel {
background-color: $ng-select-dropdown-bg;
border: 1px solid $ng-select-dropdown-border;
box-shadow: 0 1px 0 $ng-dropdown-shadow;
left: 0;
&.ng-select-top {
bottom: 100%;
border-top-right-radius: $ng-select-border-radius;
border-top-left-radius: $ng-select-border-radius;
border-bottom-color: $ng-select-border;
margin-bottom: -1px;
.ng-dropdown-panel-items {
.ng-option {
&:first-child {
border-top-right-radius: $ng-select-border-radius;
border-top-left-radius: $ng-select-border-radius;
}
}
}
}
&.ng-select-right {
left: 100%;
top: 0;
border-top-right-radius: $ng-select-border-radius;
border-bottom-right-radius: $ng-select-border-radius;
border-bottom-left-radius: $ng-select-border-radius;
border-bottom-color: $ng-select-border;
margin-bottom: -1px;
.ng-dropdown-panel-items {
.ng-option {
&:first-child {
border-top-right-radius: $ng-select-border-radius;
}
}
}
}
&.ng-select-bottom {
top: 100%;
border-bottom-right-radius: $ng-select-border-radius;
border-bottom-left-radius: $ng-select-border-radius;
border-top-color: $ng-select-border;
margin-top: -1px;
.ng-dropdown-panel-items {
.ng-option {
&:last-child {
border-bottom-right-radius: $ng-select-border-radius;
border-bottom-left-radius: $ng-select-border-radius;
}
}
}
}
&.ng-select-left {
left: -100%;
top: 0;
border-top-left-radius: $ng-select-border-radius;
border-bottom-right-radius: $ng-select-border-radius;
border-bottom-left-radius: $ng-select-border-radius;
border-bottom-color: $ng-select-border;
margin-bottom: -1px;
.ng-dropdown-panel-items {
.ng-option {
&:first-child {
border-top-left-radius: $ng-select-border-radius;
}
}
}
}
.ng-dropdown-header {
border-bottom: 1px solid $ng-select-border;
padding: 5px 7px;
}
.ng-dropdown-footer {
border-top: 1px solid $ng-select-border;
padding: 5px 7px;
}
.ng-dropdown-panel-items {
.ng-optgroup {
user-select: none;
padding: 8px 10px;
font-weight: 500;
color: $ng-select-dropdown-optgroup-text;
cursor: pointer;
&.ng-option-disabled {
cursor: default;
}
&.ng-option-marked {
background-color: $ng-select-marked;
}
&.ng-option-selected,
&.ng-option-selected.ng-option-marked {
color: $ng-select-dropdown-optgroup-marked;
background-color: $ng-select-selected;
font-weight: 600;
}
}
.ng-option {
background-color: $ng-select-dropdown-option-bg;
color: $ng-select-dropdown-option-text;
padding: 8px 10px;
&.ng-option-selected,
&.ng-option-selected.ng-option-marked {
color: $ng-select-selected-text;
background-color: $ng-select-selected;
.ng-option-label {
font-weight: 600;
}
}
&.ng-option-marked {
background-color: $ng-select-marked;
color: $ng-select-marked-text;
}
&.ng-option-disabled {
color: $ng-select-dropdown-option-disabled;
}
&.ng-option-child {
padding-left: 22px;
@include rtl {
padding-right: 22px;
padding-left: 0;
}
}
.ng-tag-label {
font-size: 80%;
font-weight: 400;
padding-right: 5px;
@include rtl {
padding-left: 5px;
padding-right: 0;
}
}
}
}
@include rtl {
direction: rtl;
text-align: right;
}
}

View File

@@ -0,0 +1,4 @@
export abstract class ButtonLikeAbstraction {
loading: boolean;
disabled: boolean;
}

View File

@@ -49,11 +49,18 @@ export const Table = (args) => (
{Row("info-500")}
{Row("info-700")}
</tbody>
<thead>
<tr>
<th>Text</th>
<th class="tw-w-20"></th>
</tr>
</thead>
<tbody>
{Row("text-main")}
{Row("text-muted")}
{Row("text-contrast")}
{Row("text-alt2")}
{Row("text-code")}
</tbody>
</table>
);

View File

@@ -8,40 +8,42 @@ $card-icons-base: "../images/cards/";
@import "@angular/cdk/overlay-prebuilt.css";
@import "~bootstrap/scss/_functions";
@import "~bootstrap/scss/_variables";
@import "~bootstrap/scss/_mixins";
@import "~bootstrap/scss/_root";
@import "~bootstrap/scss/_reboot";
@import "~bootstrap/scss/_type";
@import "~bootstrap/scss/_images";
@import "~bootstrap/scss/_code";
@import "~bootstrap/scss/_grid";
@import "~bootstrap/scss/_tables";
@import "~bootstrap/scss/_forms";
@import "~bootstrap/scss/_buttons";
@import "~bootstrap/scss/_transitions";
@import "~bootstrap/scss/_dropdown";
@import "~bootstrap/scss/_button-group";
@import "~bootstrap/scss/_input-group";
@import "~bootstrap/scss/_custom-forms";
@import "~bootstrap/scss/_nav";
@import "~bootstrap/scss/_navbar";
@import "~bootstrap/scss/_card";
@import "~bootstrap/scss/_breadcrumb";
@import "~bootstrap/scss/_pagination";
@import "~bootstrap/scss/_badge";
@import "~bootstrap/scss/_jumbotron";
@import "~bootstrap/scss/_alert";
@import "~bootstrap/scss/_progress";
@import "~bootstrap/scss/_media";
@import "~bootstrap/scss/_list-group";
@import "~bootstrap/scss/_close";
//@import "~bootstrap/scss/_toasts";
@import "~bootstrap/scss/_modal";
@import "~bootstrap/scss/_tooltip";
@import "~bootstrap/scss/_popover";
@import "~bootstrap/scss/_carousel";
@import "~bootstrap/scss/_spinners";
@import "~bootstrap/scss/_utilities";
@import "~bootstrap/scss/_print";
@import "bootstrap/scss/_functions";
@import "bootstrap/scss/_variables";
@import "bootstrap/scss/_mixins";
@import "bootstrap/scss/_root";
@import "bootstrap/scss/_reboot";
@import "bootstrap/scss/_type";
@import "bootstrap/scss/_images";
@import "bootstrap/scss/_code";
@import "bootstrap/scss/_grid";
@import "bootstrap/scss/_tables";
@import "bootstrap/scss/_forms";
@import "bootstrap/scss/_buttons";
@import "bootstrap/scss/_transitions";
@import "bootstrap/scss/_dropdown";
@import "bootstrap/scss/_button-group";
@import "bootstrap/scss/_input-group";
@import "bootstrap/scss/_custom-forms";
@import "bootstrap/scss/_nav";
@import "bootstrap/scss/_navbar";
@import "bootstrap/scss/_card";
@import "bootstrap/scss/_breadcrumb";
@import "bootstrap/scss/_pagination";
@import "bootstrap/scss/_badge";
@import "bootstrap/scss/_jumbotron";
@import "bootstrap/scss/_alert";
@import "bootstrap/scss/_progress";
@import "bootstrap/scss/_media";
@import "bootstrap/scss/_list-group";
@import "bootstrap/scss/_close";
//@import "bootstrap/scss/_toasts";
@import "bootstrap/scss/_modal";
@import "bootstrap/scss/_tooltip";
@import "bootstrap/scss/_popover";
@import "bootstrap/scss/_carousel";
@import "bootstrap/scss/_spinners";
@import "bootstrap/scss/_utilities";
@import "bootstrap/scss/_print";
@import "multi-select/scss/bw.theme.scss";

View File

@@ -30,6 +30,7 @@
--color-text-muted: 109 117 126;
--color-text-contrast: 255 255 255;
--color-text-alt2: 255 255 255;
--color-text-code: 192 17 118;
--tw-ring-offset-color: #ffffff;
}
@@ -70,6 +71,7 @@
--color-text-muted: 186 192 206;
--color-text-contrast: 25 30 38;
--color-text-alt2: 255 255 255;
--color-text-code: 240 141 199;
--tw-ring-offset-color: #1f242e;
}

View File

@@ -0,0 +1,103 @@
import { lastValueFrom, Observable, of, throwError } from "rxjs";
import { functionToObservable } from "./function-to-observable";
describe("functionToObservable", () => {
it("should execute function when calling", () => {
const func = jest.fn();
functionToObservable(func);
expect(func).toHaveBeenCalled();
});
it("should not subscribe when calling", () => {
let hasSubscribed = false;
const underlyingObservable = new Observable(() => {
hasSubscribed = true;
});
const funcReturningObservable = () => underlyingObservable;
functionToObservable(funcReturningObservable);
expect(hasSubscribed).toBe(false);
});
it("should subscribe to underlying when subscribing to outer", () => {
let hasSubscribed = false;
const underlyingObservable = new Observable(() => {
hasSubscribed = true;
});
const funcReturningObservable = () => underlyingObservable;
const outerObservable = functionToObservable(funcReturningObservable);
outerObservable.subscribe();
expect(hasSubscribed).toBe(true);
});
it("should return value when using sync function", async () => {
const value = Symbol();
const func = () => value;
const observable = functionToObservable(func);
const result = await lastValueFrom(observable);
expect(result).toBe(value);
});
it("should return value when using async function", async () => {
const value = Symbol();
const func = () => Promise.resolve(value);
const observable = functionToObservable(func);
const result = await lastValueFrom(observable);
expect(result).toBe(value);
});
it("should return value when using observable", async () => {
const value = Symbol();
const func = () => of(value);
const observable = functionToObservable(func);
const result = await lastValueFrom(observable);
expect(result).toBe(value);
});
it("should throw error when using sync function", async () => {
const error = new Error();
const func = () => {
throw error;
};
const observable = functionToObservable(func);
let thrown: unknown;
observable.subscribe({ error: (err: unknown) => (thrown = err) });
expect(thrown).toBe(thrown);
});
it("should return value when using async function", async () => {
const error = new Error();
const func = () => Promise.reject(error);
const observable = functionToObservable(func);
let thrown: unknown;
observable.subscribe({ error: (err: unknown) => (thrown = err) });
expect(thrown).toBe(thrown);
});
it("should return value when using observable", async () => {
const error = new Error();
const func = () => throwError(() => error);
const observable = functionToObservable(func);
let thrown: unknown;
observable.subscribe({ error: (err: unknown) => (thrown = err) });
expect(thrown).toBe(thrown);
});
});

View File

@@ -0,0 +1,27 @@
import { from, Observable, of, throwError } from "rxjs";
import { Utils } from "@bitwarden/common/misc/utils";
export type FunctionReturningAwaitable =
| (() => unknown)
| (() => Promise<unknown>)
| (() => Observable<unknown>);
export function functionToObservable(func: FunctionReturningAwaitable): Observable<unknown> {
let awaitable: unknown;
try {
awaitable = func();
} catch (error) {
return throwError(() => error);
}
if (Utils.isPromise(awaitable)) {
return from(awaitable);
}
if (awaitable instanceof Observable) {
return awaitable;
}
return of(awaitable);
}

View File

@@ -50,6 +50,7 @@ module.exports = {
muted: rgba("--color-text-muted"),
contrast: rgba("--color-text-contrast"),
alt2: rgba("--color-text-alt2"),
code: rgba("--color-text-code"),
},
background: {
DEFAULT: rgba("--color-background"),
@@ -62,6 +63,7 @@ module.exports = {
muted: rgba("--color-text-muted"),
contrast: rgba("--color-text-contrast"),
alt2: rgba("--color-text-alt2"),
code: rgba("--color-text-code"),
success: rgba("--color-success-500"),
danger: rgba("--color-danger-500"),
warning: rgba("--color-warning-500"),

View File

@@ -3,6 +3,7 @@ import * as http from "http";
import * as program from "commander";
import * as inquirer from "inquirer";
import Separator from "inquirer/lib/objects/separator";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
@@ -372,7 +373,9 @@ export class LoginCommand {
const masterPasswordHint = hint.input;
// Retrieve details for key generation
const enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions();
const enforcedPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$()
);
const kdf = await this.stateService.getKdfType();
const kdfIterations = await this.stateService.getKdfIterations();