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:
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>[]> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
357
libs/common/spec/services/policy.service.spec.ts
Normal file
357
libs/common/spec/services/policy.service.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
3
libs/common/src/abstractions/validation.service.ts
Normal file
3
libs/common/src/abstractions/validation.service.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export abstract class ValidationService {
|
||||
showError: (data: any) => string[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
14
libs/components/src/async-actions/async-actions.module.ts
Normal file
14
libs/components/src/async-actions/async-actions.module.ts
Normal 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 {}
|
||||
58
libs/components/src/async-actions/bit-action.directive.ts
Normal file
58
libs/components/src/async-actions/bit-action.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
82
libs/components/src/async-actions/bit-submit.directive.ts
Normal file
82
libs/components/src/async-actions/bit-submit.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
58
libs/components/src/async-actions/form-button.directive.ts
Normal file
58
libs/components/src/async-actions/form-button.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
114
libs/components/src/async-actions/in-forms.stories.mdx
Normal file
114
libs/components/src/async-actions/in-forms.stories.mdx
Normal 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>
|
||||
```
|
||||
149
libs/components/src/async-actions/in-forms.stories.ts
Normal file
149
libs/components/src/async-actions/in-forms.stories.ts
Normal 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({});
|
||||
3
libs/components/src/async-actions/index.ts
Normal file
3
libs/components/src/async-actions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./async-actions.module";
|
||||
export * from "./bit-action.directive";
|
||||
export * from "./form-button.directive";
|
||||
26
libs/components/src/async-actions/overview.stories.mdx
Normal file
26
libs/components/src/async-actions/overview.stories.mdx
Normal 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.
|
||||
63
libs/components/src/async-actions/standalone.stories.mdx
Normal file
63
libs/components/src/async-actions/standalone.stories.mdx
Normal 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>`;
|
||||
```
|
||||
97
libs/components/src/async-actions/standalone.stories.ts
Normal file
97
libs/components/src/async-actions/standalone.stories.ts
Normal 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({});
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
8
libs/components/src/form-field/form-field-control.ts
Normal file
8
libs/components/src/form-field/form-field-control.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export abstract class BitFormFieldControl {
|
||||
ariaDescribedBy: string;
|
||||
id: string;
|
||||
labelForId: string;
|
||||
required: boolean;
|
||||
hasError: boolean;
|
||||
error: [string, any];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./form-field.module";
|
||||
export * from "./form-field.component";
|
||||
export * from "./form-field-control";
|
||||
|
||||
283
libs/components/src/form-field/multi-select.stories.ts
Normal file
283
libs/components/src/form-field/multi-select.stories.ts
Normal 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",
|
||||
};
|
||||
15
libs/components/src/icon-button/icon-button.component.html
Normal file
15
libs/components/src/icon-button/icon-button.component.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./async-actions";
|
||||
export * from "./badge";
|
||||
export * from "./banner";
|
||||
export * from "./button";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
55
libs/components/src/multi-select/multi-select.component.html
Normal file
55
libs/components/src/multi-select/multi-select.component.html
Normal 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>
|
||||
179
libs/components/src/multi-select/multi-select.component.ts
Normal file
179
libs/components/src/multi-select/multi-select.component.ts
Normal 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]];
|
||||
}
|
||||
}
|
||||
16
libs/components/src/multi-select/multi-select.module.ts
Normal file
16
libs/components/src/multi-select/multi-select.module.ts
Normal 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 {}
|
||||
394
libs/components/src/multi-select/scss/bw.theme.scss
Normal file
394
libs/components/src/multi-select/scss/bw.theme.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
4
libs/components/src/shared/button-like.abstraction.ts
Normal file
4
libs/components/src/shared/button-like.abstraction.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export abstract class ButtonLikeAbstraction {
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
103
libs/components/src/utils/function-to-observable.spec.ts
Normal file
103
libs/components/src/utils/function-to-observable.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
libs/components/src/utils/function-to-observable.ts
Normal file
27
libs/components/src/utils/function-to-observable.ts
Normal 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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user