From 9ca877a7bea745fd62a0d4f1e30fe4dfa73ea9fe Mon Sep 17 00:00:00 2001 From: Alexey Zilber <110793805+alex8bitw@users.noreply.github.com> Date: Tue, 4 Oct 2022 21:38:48 +0800 Subject: [PATCH 01/22] Cloudops 165 (#3661) * Upload artifacts to R2 after we do S3. * Added beta check line to R2 upload --- .github/workflows/release-desktop-beta.yml | 20 +++++++++++++++++++- .github/workflows/release-desktop.yml | 21 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 2d34ab257e9..79a4eaa24cb 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -944,7 +944,11 @@ jobs: SECRETS: | aws-electron-access-id, aws-electron-access-key, - aws-electron-bucket-name + aws-electron-bucket-name, + r2-electron-access-id, + r2-electron-access-key, + r2-electron-bucket-name, + cf-prod-account run: | for i in ${SECRETS//,/ } do @@ -977,6 +981,20 @@ jobs: --recursive \ --quiet + - name: Publish artifacts to R2 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + working-directory: apps/desktop/artifacts + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --recursive \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + - name: Update deployment status to Success if: ${{ success() }} uses: chrnorm/deployment-status@07b3930847f65e71c9c6802ff5a402f6dfb46b86 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 9a51a3b0bea..f4e28c71460 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -98,7 +98,11 @@ jobs: SECRETS: | aws-electron-access-id, aws-electron-access-key, - aws-electron-bucket-name + aws-electron-bucket-name, + r2-electron-access-id, + r2-electron-access-key, + r2-electron-bucket-name, + cf-prod-account run: | for i in ${SECRETS//,/ } do @@ -145,6 +149,21 @@ jobs: --recursive \ --quiet + - name: Publish artifacts to R2 + if: ${{ github.event.inputs.release_type != 'Dry Run' }} + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + working-directory: apps/desktop/artifacts + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --recursive \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + - name: Create release uses: ncipollo/release-action@95215a3cb6e6a1908b3c44e00b4fdb15548b1e09 # v2.8.5 if: ${{ steps.release-channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }} From b153ed6d01397657b29288c310a0841d66d318ac Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 4 Oct 2022 15:40:00 +0200 Subject: [PATCH 02/22] [SM-265] Add eslint rule forbidding get().value (#3671) --- .eslintrc.json | 4 ++++ .../src/app/accounts/login/login.component.ts | 6 +++--- .../register-form/register-form.component.ts | 2 +- .../trial-initiation/billing.component.ts | 4 ++-- libs/angular/src/components/login.component.ts | 16 +++++++--------- .../angular/src/components/register.component.ts | 10 +++++----- .../settings/vault-timeout-input.component.ts | 6 +++--- 7 files changed, 25 insertions(+), 23 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 8485a9f30a0..583c8e3697b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -73,6 +73,10 @@ { "message": "Calling `svgIcon` directly is not allowed", "selector": "CallExpression[callee.name='svgIcon']" + }, + { + "message": "Accessing FormGroup using `get` is not allowed, use `.value` instead", + "selector": "ChainExpression[expression.object.callee.property.name='get'][expression.property.name='value']" } ], "curly": ["error", "all"], diff --git a/apps/web/src/app/accounts/login/login.component.ts b/apps/web/src/app/accounts/login/login.component.ts index 2568d5a3e74..c27ba536360 100644 --- a/apps/web/src/app/accounts/login/login.component.ts +++ b/apps/web/src/app/accounts/login/login.component.ts @@ -135,7 +135,7 @@ export class LoginComponent extends BaseLoginComponent { } async goAfterLogIn() { - const masterPassword = this.formGroup.get("masterPassword")?.value; + const masterPassword = this.formGroup.value.masterPassword; // Check master password against policy if (this.enforcedPasswordPolicyOptions != null) { @@ -170,7 +170,7 @@ export class LoginComponent extends BaseLoginComponent { } async submit() { - const rememberEmail = this.formGroup.get("rememberEmail")?.value; + const rememberEmail = this.formGroup.value.rememberEmail; await this.stateService.setRememberEmail(rememberEmail); if (!rememberEmail) { @@ -192,7 +192,7 @@ export class LoginComponent extends BaseLoginComponent { } private getPasswordStrengthUserInput() { - const email = this.formGroup.get("email")?.value; + const email = this.formGroup.value.email; let userInput: string[] = []; const atPosition = email.indexOf("@"); if (atPosition > -1) { diff --git a/apps/web/src/app/accounts/register-form/register-form.component.ts b/apps/web/src/app/accounts/register-form/register-form.component.ts index 8045ef18f1e..ad3341fb6c0 100644 --- a/apps/web/src/app/accounts/register-form/register-form.component.ts +++ b/apps/web/src/app/accounts/register-form/register-form.component.ts @@ -73,7 +73,7 @@ export class RegisterFormComponent extends BaseRegisterComponent { this.enforcedPolicyOptions != null && !this.policyService.evaluateMasterPassword( this.passwordStrengthResult.score, - this.formGroup.get("masterPassword")?.value, + this.formGroup.value.masterPassword, this.enforcedPolicyOptions ) ) { diff --git a/apps/web/src/app/accounts/trial-initiation/billing.component.ts b/apps/web/src/app/accounts/trial-initiation/billing.component.ts index aff798b5b86..0817c19c26b 100644 --- a/apps/web/src/app/accounts/trial-initiation/billing.component.ts +++ b/apps/web/src/app/accounts/trial-initiation/billing.component.ts @@ -57,8 +57,8 @@ export class BillingComponent extends OrganizationPlansComponent { async ngOnInit() { const additionalSeats = this.product == ProductType.Families ? 0 : 1; this.formGroup.patchValue({ - name: this.orgInfoForm.get("name")?.value, - billingEmail: this.orgInfoForm.get("email")?.value, + name: this.orgInfoForm.value.name, + billingEmail: this.orgInfoForm.value.email, additionalSeats: additionalSeats, plan: this.plan, product: this.product, diff --git a/libs/angular/src/components/login.component.ts b/libs/angular/src/components/login.component.ts index 1bc2e8ed871..8e35a230aa9 100644 --- a/libs/angular/src/components/login.component.ts +++ b/libs/angular/src/components/login.component.ts @@ -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(); } } diff --git a/libs/angular/src/components/register.component.ts b/libs/angular/src/components/register.component.ts index 2fba7781297..d873ae9955d 100644 --- a/libs/angular/src/components/register.component.ts +++ b/libs/angular/src/components/register.component.ts @@ -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 { - 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); diff --git a/libs/angular/src/components/settings/vault-timeout-input.component.ts b/libs/angular/src/components/settings/vault-timeout-input.component.ts index 8b5b8c24761..f63724578e2 100644 --- a/libs/angular/src/components/settings/vault-timeout-input.component.ts +++ b/libs/angular/src/components/settings/vault-timeout-input.component.ts @@ -2,7 +2,7 @@ import { Directive, Input, OnInit } from "@angular/core"; import { AbstractControl, ControlValueAccessor, - UntypedFormBuilder, + FormBuilder, ValidationErrors, Validator, } from "@angular/forms"; @@ -44,7 +44,7 @@ export class VaultTimeoutInputComponent implements ControlValueAccessor, Validat private validatorChange: () => void; constructor( - private formBuilder: UntypedFormBuilder, + private formBuilder: FormBuilder, private policyService: PolicyService, private i18nService: I18nService ) {} @@ -152,6 +152,6 @@ 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; } } From 43d586ff9900cb8bc2ee158ed19cd6823669cc64 Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Tue, 4 Oct 2022 20:43:51 +0200 Subject: [PATCH 03/22] [PS-1514] Do not subscribe to activeAccount-observable and execute load asynchronously (#3608) * Fix async subscribe * Revert "[PS-1066] Browser and Desktop - SSO User does not see Update Master Password screen after Owner does a Admin Password Reset (#3207)" This reverts commit 0eda4185911090313146842038f871c7911640a2. --- .../src/popup/accounts/lock.component.ts | 13 ++---------- .../src/app/accounts/lock.component.ts | 11 +--------- libs/angular/src/components/lock.component.ts | 21 ++++++++++++------- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/apps/browser/src/popup/accounts/lock.component.ts b/apps/browser/src/popup/accounts/lock.component.ts index c288a54a65b..775ecaa3ca0 100644 --- a/apps/browser/src/popup/accounts/lock.component.ts +++ b/apps/browser/src/popup/accounts/lock.component.ts @@ -12,7 +12,6 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus"; @@ -28,8 +27,6 @@ export class LockComponent extends BaseLockComponent { biometricError: string; pendingBiometric = false; - authenicatedUrl = "/tabs/current"; - unAuthenicatedUrl = "/update-temp-password"; constructor( router: Router, @@ -45,8 +42,7 @@ export class LockComponent extends BaseLockComponent { logService: LogService, keyConnectorService: KeyConnectorService, ngZone: NgZone, - private authService: AuthService, - private syncService: SyncService + private authService: AuthService ) { super( router, @@ -63,17 +59,12 @@ export class LockComponent extends BaseLockComponent { keyConnectorService, ngZone ); - + this.successRoute = "/tabs/current"; this.isInitialLockScreen = (window as any).previousPopupUrl == null; } async ngOnInit() { await super.ngOnInit(); - await this.syncService.fullSync(true); - - const forcePasswordReset = await this.stateService.getForcePasswordReset(); - this.successRoute = forcePasswordReset === true ? this.unAuthenicatedUrl : this.authenicatedUrl; - const disableAutoBiometricsPrompt = (await this.stateService.getDisableAutoBiometricsPrompt()) ?? true; diff --git a/apps/desktop/src/app/accounts/lock.component.ts b/apps/desktop/src/app/accounts/lock.component.ts index ff136aa982e..5cb28802796 100644 --- a/apps/desktop/src/app/accounts/lock.component.ts +++ b/apps/desktop/src/app/accounts/lock.component.ts @@ -13,7 +13,6 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; @@ -25,8 +24,6 @@ const BroadcasterSubscriptionId = "LockComponent"; }) export class LockComponent extends BaseLockComponent { private deferFocus: boolean = null; - authenicatedUrl = "vault"; - unAuthenicatedUrl = "update-temp-password"; constructor( router: Router, @@ -43,8 +40,7 @@ export class LockComponent extends BaseLockComponent { private broadcasterService: BroadcasterService, ngZone: NgZone, logService: LogService, - keyConnectorService: KeyConnectorService, - private syncService: SyncService + keyConnectorService: KeyConnectorService ) { super( router, @@ -67,11 +63,6 @@ export class LockComponent extends BaseLockComponent { await super.ngOnInit(); const autoPromptBiometric = !(await this.stateService.getNoAutoPromptBiometrics()); - await this.syncService.fullSync(true); - - const forcePasswordReset = await this.stateService.getForcePasswordReset(); - this.successRoute = forcePasswordReset === true ? this.unAuthenicatedUrl : this.authenicatedUrl; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil this.route.queryParams.subscribe((params) => { if (this.supportsBiometric && params.promptBiometric && autoPromptBiometric) { diff --git a/libs/angular/src/components/lock.component.ts b/libs/angular/src/components/lock.component.ts index 0932fab7128..a488d874461 100644 --- a/libs/angular/src/components/lock.component.ts +++ b/libs/angular/src/components/lock.component.ts @@ -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(); 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() { From 7c3255d9fa73c0df714f0323e6e46a08fdfe3646 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 5 Oct 2022 07:41:35 +1000 Subject: [PATCH 04/22] Add organization-options menu to single org (#3678) --- .../organization-filter.component.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.html b/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.html index 2977f63ed71..9d5d8b45a7c 100644 --- a/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.html +++ b/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.html @@ -97,6 +97,14 @@ {{ organizations[0].name }} + + + + + + From f2159d71ea96da81f01a81387c6b9d79d5c247b3 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Thu, 6 Oct 2022 09:54:37 -0400 Subject: [PATCH 05/22] Add staged rollout desktop workflow (#3702) --- .github/workflows/staged-rollout-desktop.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/staged-rollout-desktop.yml diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml new file mode 100644 index 00000000000..14f2b8e972b --- /dev/null +++ b/.github/workflows/staged-rollout-desktop.yml @@ -0,0 +1,17 @@ +--- +name: Staged Rollout Desktop + +on: + workflow_dispatch: + +defaults: + run: + shell: bash + +jobs: + setup: + name: Stub + runs-on: ubuntu-22.04 + steps: + - name: TEST + run: exit 0 From 8676d194d048e3ef4a1bf51a315a7940c630d2d1 Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Thu, 6 Oct 2022 12:31:31 -0400 Subject: [PATCH 06/22] fixes (#3708) --- libs/angular/src/components/export.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/angular/src/components/export.component.ts b/libs/angular/src/components/export.component.ts index 352ee1eed1a..eaa635fc5e5 100644 --- a/libs/angular/src/components/export.component.ts +++ b/libs/angular/src/components/export.component.ts @@ -117,6 +117,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(); From c0e872e5760489102506bef63d700ddb3471de75 Mon Sep 17 00:00:00 2001 From: Gbubemi Smith Date: Fri, 7 Oct 2022 16:05:20 +0100 Subject: [PATCH 07/22] added 2fa enabled (#3711) --- .../login/login-with-device.component.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/accounts/login/login-with-device.component.ts b/apps/web/src/app/accounts/login/login-with-device.component.ts index 4c6f6268dfd..32d350a1e05 100644 --- a/apps/web/src/app/accounts/login/login-with-device.component.ts +++ b/apps/web/src/app/accounts/login/login-with-device.component.ts @@ -37,9 +37,11 @@ export class LoginWithDeviceComponent onSuccessfulLoginTwoFactorNavigate: () => Promise; onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginForceResetNavigate: () => Promise; protected twoFactorRoute = "2fa"; protected successRoute = "vault"; + protected forcePasswordResetRoute = "update-temp-password"; private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer]; constructor( @@ -119,14 +121,29 @@ export class LoginWithDeviceComponent } const credentials = await this.buildLoginCredntials(requestId, response); - await this.authService.logIn(credentials); - if (this.onSuccessfulLogin != null) { - this.onSuccessfulLogin(); - } - if (this.onSuccessfulLoginNavigate != null) { - this.onSuccessfulLoginNavigate(); + const loginResponse = await this.authService.logIn(credentials); + + if (loginResponse.requiresTwoFactor) { + if (this.onSuccessfulLoginTwoFactorNavigate != null) { + this.onSuccessfulLoginTwoFactorNavigate(); + } else { + this.router.navigate([this.twoFactorRoute]); + } + } else if (loginResponse.forcePasswordReset) { + if (this.onSuccessfulLoginForceResetNavigate != null) { + this.onSuccessfulLoginForceResetNavigate(); + } else { + this.router.navigate([this.forcePasswordResetRoute]); + } } else { - this.router.navigate([this.successRoute]); + if (this.onSuccessfulLogin != null) { + this.onSuccessfulLogin(); + } + if (this.onSuccessfulLoginNavigate != null) { + this.onSuccessfulLoginNavigate(); + } else { + this.router.navigate([this.successRoute]); + } } } catch (error) { this.logService.error(error); From 96c99058c4a490e3f9192b21eec886209dcf32fa Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Fri, 7 Oct 2022 18:24:49 +0200 Subject: [PATCH 08/22] [CL-42] Add code color (#3662) --- libs/components/src/stories/colors.stories.mdx | 7 +++++++ libs/components/src/tw-theme.css | 2 ++ libs/components/tailwind.config.base.js | 2 ++ 3 files changed, 11 insertions(+) diff --git a/libs/components/src/stories/colors.stories.mdx b/libs/components/src/stories/colors.stories.mdx index 255b1e542a0..2daaa0325d1 100644 --- a/libs/components/src/stories/colors.stories.mdx +++ b/libs/components/src/stories/colors.stories.mdx @@ -49,11 +49,18 @@ export const Table = (args) => ( {Row("info-500")} {Row("info-700")} + + + Text + + + {Row("text-main")} {Row("text-muted")} {Row("text-contrast")} {Row("text-alt2")} + {Row("text-code")} ); diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index f76c838585d..7acdb02ebf7 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -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; } diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index 082abe73726..5abb5694271 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -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"), From bb4f063fe7e088deb7b9f4e1c1e9d03023c24da0 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Mon, 10 Oct 2022 16:04:29 +0200 Subject: [PATCH 09/22] [EC-558] Reflecting async progress on buttons and forms (#3548) * [EC-556] feat: convert button into component * [EC-556] feat: implement loading state * [EC-556] feat: remove loading from submit button * [EC-556] fix: add missing import * [EC-556] fix: disabling button using regular attribute * [EC-556] feat: implement bitFormButton * [EC-556] feat: use bitFormButton in submit button * [EC-556] fix: missing import * [EC-558] chore: rename file to match class name * [EC-558] feat: allow skipping bitButton on form buttons * [EC-558]: only show spinner on submit button * [EC-558] feat: add new bit async directive * [EC-558] feat: add functionToObservable util * [EC-558] feat: implement bitAction directive * [EC-558] refactor: simplify bitSubmit using functionToObservable * [EC-558] feat: connect bit action with form button * [EC-558] feat: execute function immediately to allow for form validation * [EC-558] feat: disable form on loading * [EC-558] chore: remove duplicate types * [EC-558] feat: move validation service to common * [EC-558] feat: add error handling using validation service * [EC-558] feat: add support for icon button * [EC-558] fix: icon button hover border styles * [EC-558] chore: refactor icon button story to show all styles * [EC-558] fix: better align loading spinner to middle * [EC-558] fix: simplify try catch * [EC-558] chore: reorganize async actions * [EC-558] chore: rename stories * [EC-558] docs: add documentation * [EC-558] feat: decouple buttons and form buttons * [EC-558] chore: rename button like abstraction * [EC-558] chore: remove null check * [EC-558] docs: add jsdocs to directives * [EC-558] fix: switch abs imports to relative * [EC-558] chore: add async actions module to web shared module * [EC-558] chore: remove unecessary null check * [EC-558] chore: apply suggestions from code review Co-authored-by: Oscar Hinton * [EC-558] fix: whitespaces * [EC-558] feat: dont disable form by default * [EC-558] fix: bug where form could be submit during a previous submit * [EC-558] feat: remove ability to disable form Co-authored-by: Oscar Hinton --- .../src/app/common/base.people.component.ts | 2 +- .../organizations/manage/people.component.ts | 2 +- ...families-for-enterprise-setup.component.ts | 2 +- apps/web/src/app/shared/shared.module.ts | 2 + .../clients/add-organization.component.ts | 2 +- .../providers/clients/clients.component.ts | 2 +- .../app/providers/manage/people.component.ts | 2 +- .../app/providers/setup/setup.component.ts | 2 +- .../src/directives/api-action.directive.ts | 3 +- .../src/services/jslib-services.module.ts | 9 +- .../src/abstractions/validation.service.ts | 3 + libs/common/src/misc/utils.ts | 6 + .../src/services/validation.service.ts | 12 +- .../src/async-actions/async-actions.module.ts | 14 ++ .../src/async-actions/bit-action.directive.ts | 58 +++++++ .../src/async-actions/bit-submit.directive.ts | 83 ++++++++++ .../async-actions/form-button.directive.ts | 58 +++++++ .../src/async-actions/in-forms.stories.mdx | 114 +++++++++++++ .../src/async-actions/in-forms.stories.ts | 156 ++++++++++++++++++ libs/components/src/async-actions/index.ts | 3 + .../src/async-actions/overview.stories.mdx | 26 +++ .../src/async-actions/standalone.stories.mdx | 63 +++++++ .../src/async-actions/standalone.stories.ts | 97 +++++++++++ .../src/button/button.component.html | 7 +- .../components/src/button/button.component.ts | 5 +- .../icon-button/icon-button.component.html | 15 ++ .../src/icon-button/icon-button.component.ts | 29 +++- .../src/icon-button/icon-button.stories.ts | 108 +++++++----- libs/components/src/index.ts | 1 + .../src/shared/button-like.abstraction.ts | 4 + .../src/utils/function-to-observable.spec.ts | 103 ++++++++++++ .../src/utils/function-to-observable.ts | 27 +++ 32 files changed, 955 insertions(+), 65 deletions(-) create mode 100644 libs/common/src/abstractions/validation.service.ts rename libs/{angular => common}/src/services/validation.service.ts (72%) create mode 100644 libs/components/src/async-actions/async-actions.module.ts create mode 100644 libs/components/src/async-actions/bit-action.directive.ts create mode 100644 libs/components/src/async-actions/bit-submit.directive.ts create mode 100644 libs/components/src/async-actions/form-button.directive.ts create mode 100644 libs/components/src/async-actions/in-forms.stories.mdx create mode 100644 libs/components/src/async-actions/in-forms.stories.ts create mode 100644 libs/components/src/async-actions/index.ts create mode 100644 libs/components/src/async-actions/overview.stories.mdx create mode 100644 libs/components/src/async-actions/standalone.stories.mdx create mode 100644 libs/components/src/async-actions/standalone.stories.ts create mode 100644 libs/components/src/icon-button/icon-button.component.html create mode 100644 libs/components/src/shared/button-like.abstraction.ts create mode 100644 libs/components/src/utils/function-to-observable.spec.ts create mode 100644 libs/components/src/utils/function-to-observable.ts diff --git a/apps/web/src/app/common/base.people.component.ts b/apps/web/src/app/common/base.people.component.ts index 8d9a3324244..e7c2c8f4ea9 100644 --- a/apps/web/src/app/common/base.people.component.ts +++ b/apps/web/src/app/common/base.people.component.ts @@ -3,7 +3,6 @@ import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -11,6 +10,7 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStatusType"; diff --git a/apps/web/src/app/organizations/manage/people.component.ts b/apps/web/src/app/organizations/manage/people.component.ts index d8535c14352..94e9493431a 100644 --- a/apps/web/src/app/organizations/manage/people.component.ts +++ b/apps/web/src/app/organizations/manage/people.component.ts @@ -5,7 +5,6 @@ import { first } from "rxjs/operators"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -18,6 +17,7 @@ import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.serv import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { PolicyType } from "@bitwarden/common/enums/policyType"; diff --git a/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts b/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts index 7d7f8193065..9ce3d599325 100644 --- a/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts +++ b/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts @@ -4,12 +4,12 @@ import { Observable, Subject } from "rxjs"; import { first, map, takeUntil } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { PlanSponsorshipType } from "@bitwarden/common/enums/planSponsorshipType"; import { PlanType } from "@bitwarden/common/enums/planType"; import { ProductType } from "@bitwarden/common/enums/productType"; diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index c0a076e545e..f8c79ebb5e4 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -14,6 +14,7 @@ import { FormFieldModule, MenuModule, IconModule, + AsyncActionsModule, } from "@bitwarden/components"; // Register the locales for the application @@ -47,6 +48,7 @@ import "./locales"; ], exports: [ CommonModule, + AsyncActionsModule, DragDropModule, FormsModule, InfiniteScrollModule, diff --git a/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts b/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts index 8544acc4052..292c4649a9a 100644 --- a/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts @@ -1,9 +1,9 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ProviderService } from "@bitwarden/common/abstractions/provider.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { Organization } from "@bitwarden/common/models/domain/organization"; import { Provider } from "@bitwarden/common/models/domain/provider"; diff --git a/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts index f98a7f3ac18..a87302511a2 100644 --- a/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts @@ -3,7 +3,6 @@ import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; @@ -12,6 +11,7 @@ import { OrganizationService } from "@bitwarden/common/abstractions/organization import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ProviderService } from "@bitwarden/common/abstractions/provider.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { PlanType } from "@bitwarden/common/enums/planType"; import { ProviderUserType } from "@bitwarden/common/enums/providerUserType"; import { Organization } from "@bitwarden/common/models/domain/organization"; diff --git a/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts b/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts index 2ffd0e7a53b..bdb7c30e904 100644 --- a/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts @@ -5,7 +5,6 @@ import { first } from "rxjs/operators"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -14,6 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti import { ProviderService } from "@bitwarden/common/abstractions/provider.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStatusType"; import { ProviderUserType } from "@bitwarden/common/enums/providerUserType"; import { ProviderUserBulkRequest } from "@bitwarden/common/models/request/provider/providerUserBulkRequest"; diff --git a/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts index 531f1e9e21a..bdc9b498263 100644 --- a/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts @@ -2,12 +2,12 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ProviderSetupRequest } from "@bitwarden/common/models/request/provider/providerSetupRequest"; @Component({ diff --git a/libs/angular/src/directives/api-action.directive.ts b/libs/angular/src/directives/api-action.directive.ts index 4a4e06201ff..f68e7f8665a 100644 --- a/libs/angular/src/directives/api-action.directive.ts +++ b/libs/angular/src/directives/api-action.directive.ts @@ -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
element and provide the name of the class property that will hold the api call promise. diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 83ed944bc89..b1bbcc0a87b 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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 {} diff --git a/libs/common/src/abstractions/validation.service.ts b/libs/common/src/abstractions/validation.service.ts new file mode 100644 index 00000000000..c0985847bff --- /dev/null +++ b/libs/common/src/abstractions/validation.service.ts @@ -0,0 +1,3 @@ +export abstract class ValidationService { + showError: (data: any) => string[]; +} diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/misc/utils.ts index 21ddb651e08..f3f03c30eed 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/misc/utils.ts @@ -339,6 +339,12 @@ export class Utils { return str == null || typeof str !== "string" || str == ""; } + static isPromise(obj: any): obj is Promise { + return ( + obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function" + ); + } + static nameOf(name: string & keyof T) { return name; } diff --git a/libs/angular/src/services/validation.service.ts b/libs/common/src/services/validation.service.ts similarity index 72% rename from libs/angular/src/services/validation.service.ts rename to libs/common/src/services/validation.service.ts index a9d4dbbf335..b51d55a2d80 100644 --- a/libs/angular/src/services/validation.service.ts +++ b/libs/common/src/services/validation.service.ts @@ -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 diff --git a/libs/components/src/async-actions/async-actions.module.ts b/libs/components/src/async-actions/async-actions.module.ts new file mode 100644 index 00000000000..8ff1deb2784 --- /dev/null +++ b/libs/components/src/async-actions/async-actions.module.ts @@ -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 {} diff --git a/libs/components/src/async-actions/bit-action.directive.ts b/libs/components/src/async-actions/bit-action.directive.ts new file mode 100644 index 00000000000..4fb28a4b1fb --- /dev/null +++ b/libs/components/src/async-actions/bit-action.directive.ts @@ -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(); + private _loading$ = new BehaviorSubject(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(); + } +} diff --git a/libs/components/src/async-actions/bit-submit.directive.ts b/libs/components/src/async-actions/bit-submit.directive.ts new file mode 100644 index 00000000000..334696576b5 --- /dev/null +++ b/libs/components/src/async-actions/bit-submit.directive.ts @@ -0,0 +1,83 @@ +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(); + private _loading$ = new BehaviorSubject(false); + private _disabled$ = new BehaviorSubject(false); + + @Input("bitSubmit") protected handler: FunctionReturningAwaitable; + @Input("disableFormOnLoading") protected disableFormOnLoading = false; + + 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(); + } +} diff --git a/libs/components/src/async-actions/form-button.directive.ts b/libs/components/src/async-actions/form-button.directive.ts new file mode 100644 index 00000000000..20ca289f7bc --- /dev/null +++ b/libs/components/src/async-actions/form-button.directive.ts @@ -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(); + + @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(); + } +} diff --git a/libs/components/src/async-actions/in-forms.stories.mdx b/libs/components/src/async-actions/in-forms.stories.mdx new file mode 100644 index 00000000000..75bedda8ebc --- /dev/null +++ b/libs/components/src/async-actions/in-forms.stories.mdx @@ -0,0 +1,114 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# 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 +... +``` + +### 3. Add directive to the `type="submit"` button + +Add both `bitButton` and `bitFormButton` directives to the button. + +```html + +``` + +## 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 +
...
+``` + +### 3. Add directives to the `button` element + +Add `bitButton`, `bitFormButton`, `bitAction` directives to the button. Make sure to supply a handler. + +```html + + +``` diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts new file mode 100644 index 00000000000..ce0528cc04f --- /dev/null +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -0,0 +1,156 @@ +import { Component, Input } 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 = ` +
+ + Name + + + + + Email + + + + + + + +
`; + +@Component({ + selector: "app-promise-example", + template, +}) +class PromiseExampleComponent { + formObj = this.formBuilder.group({ + name: ["", [Validators.required]], + email: ["", [Validators.required, Validators.email]], + }); + + @Input() disableFormOnLoading: boolean; + + constructor(private formBuilder: FormBuilder) {} + + submit = async () => { + this.formObj.markAllAsTouched(); + + if (!this.formObj.valid) { + return; + } + + await new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }; + + delete = async () => { + await new Promise((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]], + }); + + @Input() disableFormOnLoading: boolean; + + 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, + }, + ], + }), + ], + args: { + disableFormOnLoading: false, + }, +} as Meta; + +const PromiseTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingPromise = PromiseTemplate.bind({}); + +const ObservableTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingObservable = ObservableTemplate.bind({}); diff --git a/libs/components/src/async-actions/index.ts b/libs/components/src/async-actions/index.ts new file mode 100644 index 00000000000..6515ffc47ca --- /dev/null +++ b/libs/components/src/async-actions/index.ts @@ -0,0 +1,3 @@ +export * from "./async-actions.module"; +export * from "./bit-action.directive"; +export * from "./form-button.directive"; diff --git a/libs/components/src/async-actions/overview.stories.mdx b/libs/components/src/async-actions/overview.stories.mdx new file mode 100644 index 00000000000..9ec792aefdd --- /dev/null +++ b/libs/components/src/async-actions/overview.stories.mdx @@ -0,0 +1,26 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# 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. diff --git a/libs/components/src/async-actions/standalone.stories.mdx b/libs/components/src/async-actions/standalone.stories.mdx new file mode 100644 index 00000000000..7ed5c46ffde --- /dev/null +++ b/libs/components/src/async-actions/standalone.stories.mdx @@ -0,0 +1,63 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# 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 + + +`; +``` diff --git a/libs/components/src/async-actions/standalone.stories.ts b/libs/components/src/async-actions/standalone.stories.ts new file mode 100644 index 00000000000..cd0c6239b06 --- /dev/null +++ b/libs/components/src/async-actions/standalone.stories.ts @@ -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 = ` + + `; + +@Component({ + template, + selector: "app-promise-example", +}) +class PromiseExampleComponent { + action = async () => { + await new Promise((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((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, + }, + ], + }), + ], +} as Meta; + +const PromiseTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingPromise = PromiseTemplate.bind({}); + +const ObservableTemplate: Story = ( + args: ObservableExampleComponent +) => ({ + template: ``, +}); + +export const UsingObservable = ObservableTemplate.bind({}); + +const RejectedPromiseTemplate: Story = ( + args: ObservableExampleComponent +) => ({ + template: ``, +}); + +export const RejectedPromise = RejectedPromiseTemplate.bind({}); diff --git a/libs/components/src/button/button.component.html b/libs/components/src/button/button.component.html index 4875c159e92..ee4d150dfcc 100644 --- a/libs/components/src/button/button.component.html +++ b/libs/components/src/button/button.component.html @@ -2,7 +2,10 @@ - - + + diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index eeba83b8156..9a27bfdd9ed 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -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 = { @@ -41,8 +43,9 @@ const buttonStyles: Record = { @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", diff --git a/libs/components/src/icon-button/icon-button.component.html b/libs/components/src/icon-button/icon-button.component.html new file mode 100644 index 00000000000..6eeaaaffaf0 --- /dev/null +++ b/libs/components/src/icon-button/icon-button.component.html @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 6696935257f..ef9474bf98d 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -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 = { +export type IconButtonType = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger"; + +const styles: Record = { contrast: [ "tw-bg-transparent", "!tw-text-contrast", @@ -10,6 +12,7 @@ const styles: Record = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { @Component({ selector: "button[bitIconButton]", - template: ``, + 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; } diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 7be45d3f719..69350945d96 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -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 = (args: BitIconButtonComponent) => ({ - props: args, + props: { ...args, buttonTypes }, template: ` -
- -
+ + + + + + + + + + + + + + + + + + + + + + + + +
{{buttonType}}
Default + +
Disabled + +
Loading + +
`, }); -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", }; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 264c655d80f..24095107a30 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,3 +1,4 @@ +export * from "./async-actions"; export * from "./badge"; export * from "./banner"; export * from "./button"; diff --git a/libs/components/src/shared/button-like.abstraction.ts b/libs/components/src/shared/button-like.abstraction.ts new file mode 100644 index 00000000000..21c57461d23 --- /dev/null +++ b/libs/components/src/shared/button-like.abstraction.ts @@ -0,0 +1,4 @@ +export abstract class ButtonLikeAbstraction { + loading: boolean; + disabled: boolean; +} diff --git a/libs/components/src/utils/function-to-observable.spec.ts b/libs/components/src/utils/function-to-observable.spec.ts new file mode 100644 index 00000000000..17b05d817f0 --- /dev/null +++ b/libs/components/src/utils/function-to-observable.spec.ts @@ -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); + }); +}); diff --git a/libs/components/src/utils/function-to-observable.ts b/libs/components/src/utils/function-to-observable.ts new file mode 100644 index 00000000000..ab619ae75cf --- /dev/null +++ b/libs/components/src/utils/function-to-observable.ts @@ -0,0 +1,27 @@ +import { from, Observable, of, throwError } from "rxjs"; + +import { Utils } from "@bitwarden/common/misc/utils"; + +export type FunctionReturningAwaitable = + | (() => unknown) + | (() => Promise) + | (() => Observable); + +export function functionToObservable(func: FunctionReturningAwaitable): Observable { + 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); +} From f6b2b75ad8696a775c86c90742acf11ba47738aa Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 10 Oct 2022 17:19:01 +0200 Subject: [PATCH 10/22] Forbid substitute (#3734) --- .eslintrc.json | 5 ++++- .../src/services/localBackedSessionStorage.service.spec.ts | 1 + apps/browser/src/services/state.service.spec.ts | 1 + apps/desktop/src/app/vault/generator.component.spec.ts | 1 + apps/web/.eslintrc.json | 3 ++- .../trial-initiation/trial-initiation.component.spec.ts | 1 + libs/common/spec/importers/bitwardenJsonImporter.spec.ts | 1 + .../importers/bitwardenPasswordProtectedImporter.spec.ts | 1 + .../spec/misc/logInStrategies/apiLogIn.strategy.spec.ts | 1 + libs/common/spec/misc/logInStrategies/logIn.strategy.spec.ts | 1 + .../spec/misc/logInStrategies/passwordLogIn.strategy.spec.ts | 1 + .../spec/misc/logInStrategies/ssoLogIn.strategy.spec.ts | 1 + libs/common/spec/models/domain/cipher.spec.ts | 1 + libs/common/spec/models/domain/encString.spec.ts | 1 + libs/common/spec/models/domain/login.spec.ts | 1 + libs/common/spec/models/domain/send.spec.ts | 1 + libs/common/spec/models/domain/sendAccess.spec.ts | 1 + libs/common/spec/services/cipher.service.spec.ts | 1 + libs/common/spec/services/export.service.spec.ts | 1 + libs/common/spec/services/folder.service.spec.ts | 1 + libs/common/spec/services/import.service.spec.ts | 1 + libs/common/spec/services/settings.service.spec.ts | 1 + libs/common/spec/services/stateMigration.service.spec.ts | 1 + libs/common/spec/utils.ts | 1 + .../spec/web/services/webCryptoFunction.service.spec.ts | 1 + 25 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 583c8e3697b..14b9a888444 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -101,7 +101,10 @@ ] } ], - "no-restricted-imports": ["error", { "patterns": ["src/**/*"] }] + "no-restricted-imports": [ + "error", + { "patterns": ["src/**/*"], "paths": ["@fluffy-spoon/substitute"] } + ] }, "overrides": [ { diff --git a/apps/browser/src/services/localBackedSessionStorage.service.spec.ts b/apps/browser/src/services/localBackedSessionStorage.service.spec.ts index f7101ddae2e..1d105ce3e4c 100644 --- a/apps/browser/src/services/localBackedSessionStorage.service.spec.ts +++ b/apps/browser/src/services/localBackedSessionStorage.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { Utils } from "@bitwarden/common/misc/utils"; diff --git a/apps/browser/src/services/state.service.spec.ts b/apps/browser/src/services/state.service.spec.ts index f3b6c74a5e3..f9aa6fc6433 100644 --- a/apps/browser/src/services/state.service.spec.ts +++ b/apps/browser/src/services/state.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { LogService } from "@bitwarden/common/abstractions/log.service"; diff --git a/apps/desktop/src/app/vault/generator.component.spec.ts b/apps/desktop/src/app/vault/generator.component.spec.ts index 6c906e9b367..59638bb7584 100644 --- a/apps/desktop/src/app/vault/generator.component.spec.ts +++ b/apps/desktop/src/app/vault/generator.component.spec.ts @@ -1,6 +1,7 @@ import { NO_ERRORS_SCHEMA } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute } from "@angular/router"; +// eslint-disable-next-line no-restricted-imports import { Substitute } from "@fluffy-spoon/substitute"; import { mock, MockProxy } from "jest-mock-extended"; diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json index 6f171a4bed1..6c519d70ae1 100644 --- a/apps/web/.eslintrc.json +++ b/apps/web/.eslintrc.json @@ -12,7 +12,8 @@ "**/app/shared/*", "@bitwarden/web-vault/*", "src/**/*" - ] + ], + "paths": ["@fluffy-spoon/substitute"] } ] } diff --git a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts index f20b407e6bf..fd3552ebafd 100644 --- a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts +++ b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts @@ -5,6 +5,7 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testin import { FormBuilder, UntypedFormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; +// eslint-disable-next-line no-restricted-imports import { Substitute } from "@fluffy-spoon/substitute"; import { BehaviorSubject } from "rxjs"; diff --git a/libs/common/spec/importers/bitwardenJsonImporter.spec.ts b/libs/common/spec/importers/bitwardenJsonImporter.spec.ts index 46cf44207a7..2184162ee13 100644 --- a/libs/common/spec/importers/bitwardenJsonImporter.spec.ts +++ b/libs/common/spec/importers/bitwardenJsonImporter.spec.ts @@ -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"; diff --git a/libs/common/spec/importers/bitwardenPasswordProtectedImporter.spec.ts b/libs/common/spec/importers/bitwardenPasswordProtectedImporter.spec.ts index 67e03d030c5..98c2edef420 100644 --- a/libs/common/spec/importers/bitwardenPasswordProtectedImporter.spec.ts +++ b/libs/common/spec/importers/bitwardenPasswordProtectedImporter.spec.ts @@ -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"; diff --git a/libs/common/spec/misc/logInStrategies/apiLogIn.strategy.spec.ts b/libs/common/spec/misc/logInStrategies/apiLogIn.strategy.spec.ts index 81672fe39e3..496ecf9cdd5 100644 --- a/libs/common/spec/misc/logInStrategies/apiLogIn.strategy.spec.ts +++ b/libs/common/spec/misc/logInStrategies/apiLogIn.strategy.spec.ts @@ -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"; diff --git a/libs/common/spec/misc/logInStrategies/logIn.strategy.spec.ts b/libs/common/spec/misc/logInStrategies/logIn.strategy.spec.ts index 92eb27de079..a2774960867 100644 --- a/libs/common/spec/misc/logInStrategies/logIn.strategy.spec.ts +++ b/libs/common/spec/misc/logInStrategies/logIn.strategy.spec.ts @@ -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"; diff --git a/libs/common/spec/misc/logInStrategies/passwordLogIn.strategy.spec.ts b/libs/common/spec/misc/logInStrategies/passwordLogIn.strategy.spec.ts index f244f5f5bdf..bdae82cd53e 100644 --- a/libs/common/spec/misc/logInStrategies/passwordLogIn.strategy.spec.ts +++ b/libs/common/spec/misc/logInStrategies/passwordLogIn.strategy.spec.ts @@ -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"; diff --git a/libs/common/spec/misc/logInStrategies/ssoLogIn.strategy.spec.ts b/libs/common/spec/misc/logInStrategies/ssoLogIn.strategy.spec.ts index 1526fca548b..68e0eb2c547 100644 --- a/libs/common/spec/misc/logInStrategies/ssoLogIn.strategy.spec.ts +++ b/libs/common/spec/misc/logInStrategies/ssoLogIn.strategy.spec.ts @@ -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"; diff --git a/libs/common/spec/models/domain/cipher.spec.ts b/libs/common/spec/models/domain/cipher.spec.ts index 47d0d4f547f..763e57bb4e7 100644 --- a/libs/common/spec/models/domain/cipher.spec.ts +++ b/libs/common/spec/models/domain/cipher.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Substitute, Arg } from "@fluffy-spoon/substitute"; import { Jsonify } from "type-fest"; diff --git a/libs/common/spec/models/domain/encString.spec.ts b/libs/common/spec/models/domain/encString.spec.ts index a35a02ecc2c..a2308080018 100644 --- a/libs/common/spec/models/domain/encString.spec.ts +++ b/libs/common/spec/models/domain/encString.spec.ts @@ -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"; diff --git a/libs/common/spec/models/domain/login.spec.ts b/libs/common/spec/models/domain/login.spec.ts index 9234051f417..d22373ec7fc 100644 --- a/libs/common/spec/models/domain/login.spec.ts +++ b/libs/common/spec/models/domain/login.spec.ts @@ -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"; diff --git a/libs/common/spec/models/domain/send.spec.ts b/libs/common/spec/models/domain/send.spec.ts index 903043f86c8..7c0795588c8 100644 --- a/libs/common/spec/models/domain/send.spec.ts +++ b/libs/common/spec/models/domain/send.spec.ts @@ -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"; diff --git a/libs/common/spec/models/domain/sendAccess.spec.ts b/libs/common/spec/models/domain/sendAccess.spec.ts index 32b01d13fd4..98f67a68c8a 100644 --- a/libs/common/spec/models/domain/sendAccess.spec.ts +++ b/libs/common/spec/models/domain/sendAccess.spec.ts @@ -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"; diff --git a/libs/common/spec/services/cipher.service.spec.ts b/libs/common/spec/services/cipher.service.spec.ts index 1a654bed6b2..ea7b082e149 100644 --- a/libs/common/spec/services/cipher.service.spec.ts +++ b/libs/common/spec/services/cipher.service.spec.ts @@ -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"; diff --git a/libs/common/spec/services/export.service.spec.ts b/libs/common/spec/services/export.service.spec.ts index bad26ab351e..4bc44d21e08 100644 --- a/libs/common/spec/services/export.service.spec.ts +++ b/libs/common/spec/services/export.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { BehaviorSubject } from "rxjs"; diff --git a/libs/common/spec/services/folder.service.spec.ts b/libs/common/spec/services/folder.service.spec.ts index 211c1f788e3..0f78a574837 100644 --- a/libs/common/spec/services/folder.service.spec.ts +++ b/libs/common/spec/services/folder.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { BehaviorSubject, firstValueFrom } from "rxjs"; diff --git a/libs/common/spec/services/import.service.spec.ts b/libs/common/spec/services/import.service.spec.ts index 2b47518bc5d..f2ae48a09ee 100644 --- a/libs/common/spec/services/import.service.spec.ts +++ b/libs/common/spec/services/import.service.spec.ts @@ -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"; diff --git a/libs/common/spec/services/settings.service.spec.ts b/libs/common/spec/services/settings.service.spec.ts index f81e4d341ca..a52ef59a60b 100644 --- a/libs/common/spec/services/settings.service.spec.ts +++ b/libs/common/spec/services/settings.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { BehaviorSubject, firstValueFrom } from "rxjs"; diff --git a/libs/common/spec/services/stateMigration.service.spec.ts b/libs/common/spec/services/stateMigration.service.spec.ts index b0188abacce..5512e49753a 100644 --- a/libs/common/spec/services/stateMigration.service.spec.ts +++ b/libs/common/spec/services/stateMigration.service.spec.ts @@ -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"; diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index d3729d14e50..2c637f9adff 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -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"; diff --git a/libs/common/spec/web/services/webCryptoFunction.service.spec.ts b/libs/common/spec/web/services/webCryptoFunction.service.spec.ts index ce131472d4b..08deda8827a 100644 --- a/libs/common/spec/web/services/webCryptoFunction.service.spec.ts +++ b/libs/common/spec/web/services/webCryptoFunction.service.spec.ts @@ -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"; From fe1a895e6bd0d4847a039547deee0cf00497a286 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Mon, 10 Oct 2022 13:07:12 -0400 Subject: [PATCH 11/22] [SG-720] Trim c null characters getting padded at end of messages (#3724) * Trim everything at the end of decrypted payload before parsing * Clarify comment * Use char code check for nulls * Extract trim code to function * make char codes constants --- .../services/nativeMessageHandler.service.ts | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/services/nativeMessageHandler.service.ts b/apps/desktop/src/services/nativeMessageHandler.service.ts index 916bd86bc89..5787e407343 100644 --- a/apps/desktop/src/services/nativeMessageHandler.service.ts +++ b/apps/desktop/src/services/nativeMessageHandler.service.ts @@ -182,12 +182,25 @@ export class NativeMessageHandlerService { this.ddgSharedSecret = SymmetricCryptoKey.fromJSON({ keyB64: storedKey }); } - return JSON.parse( - await this.cryptoService.decryptToUtf8( + try { + let decryptedResult = await this.cryptoService.decryptToUtf8( message.encryptedCommand as EncString, this.ddgSharedSecret - ) - ); + ); + + decryptedResult = this.trimNullCharsFromMessage(decryptedResult); + + return JSON.parse(decryptedResult); + } catch { + this.sendResponse({ + messageId: message.messageId, + version: NativeMessagingVersion.Latest, + payload: { + error: "cannot-decrypt", + }, + }); + return; + } } private async sendEncryptedResponse( @@ -218,4 +231,23 @@ export class NativeMessageHandlerService { private sendResponse(response: EncryptedMessageResponse | UnencryptedMessageResponse) { ipcRenderer.send("nativeMessagingReply", response); } + + // Trim all null bytes padded at the end of messages. This happens with C encryption libraries. + private trimNullCharsFromMessage(message: string): string { + const charNull = 0; + const charRightCurlyBrace = 125; + const charRightBracket = 93; + + for (let i = message.length - 1; i >= 0; i--) { + if (message.charCodeAt(i) === charNull) { + message = message.substring(0, message.length - 1); + } else if ( + message.charCodeAt(i) === charRightCurlyBrace || + message.charCodeAt(i) === charRightBracket + ) { + break; + } + } + return message; + } } From 333bc279726ace4586c4bb6386275bd65f24347e Mon Sep 17 00:00:00 2001 From: Opeyemi <54288773+Eebru-gzy@users.noreply.github.com> Date: Mon, 10 Oct 2022 20:35:44 +0100 Subject: [PATCH 12/22] Update download artifact action (#3735) --- .github/workflows/release-browser.yml | 4 ++-- .github/workflows/release-cli.yml | 16 ++++++++-------- .github/workflows/release-desktop.yml | 12 ++++++------ .github/workflows/release-web.yml | 8 ++++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index 7279d3b08b4..514dafa65f8 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -102,7 +102,7 @@ jobs: - name: Download latest Release build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-browser.yml workflow_conclusion: success @@ -115,7 +115,7 @@ jobs: - name: Download latest master build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-browser.yml workflow_conclusion: success diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index c3ddbb98144..cad548f2b0d 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -76,7 +76,7 @@ jobs: - name: Download all Release artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -85,7 +85,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -167,7 +167,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -177,7 +177,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -232,7 +232,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/dist @@ -242,7 +242,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/dist @@ -289,7 +289,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/build @@ -299,7 +299,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/build diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index f4e28c71460..85e9122b335 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -113,7 +113,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -122,7 +122,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -259,7 +259,7 @@ jobs: - name: Download Snap artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -269,7 +269,7 @@ jobs: - name: Download Snap artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -332,7 +332,7 @@ jobs: - name: Download choco artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -342,7 +342,7 @@ jobs: - name: Download choco artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index 23f99c8f9b3..e52f6d5aa3a 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -154,7 +154,7 @@ jobs: - name: Download latest cloud asset if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web @@ -164,7 +164,7 @@ jobs: - name: Download latest cloud asset if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web @@ -240,7 +240,7 @@ jobs: - name: Download latest build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web/artifacts @@ -251,7 +251,7 @@ jobs: - name: Download latest build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web/artifacts From be040080dbdd172b0b938015672619c2949408dc Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 11 Oct 2022 06:57:45 +1000 Subject: [PATCH 13/22] Mark modalService.openViewRef as deprecated (#3697) --- libs/angular/src/services/modal.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libs/angular/src/services/modal.service.ts b/libs/angular/src/services/modal.service.ts index 348e3f88c3d..18afcbbea84 100644 --- a/libs/angular/src/services/modal.service.ts +++ b/libs/angular/src/services/modal.service.ts @@ -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 `` associated with the + * existing usage. + */ async openViewRef( componentType: Type, viewContainerRef: ViewContainerRef, From ea12ee2b104b646fd3e39290a317792ca19702b5 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 11 Oct 2022 10:50:46 +0200 Subject: [PATCH 14/22] [EC-558] chore: cleanup unused code (#3740) --- .../src/async-actions/bit-submit.directive.ts | 1 - .../src/async-actions/in-forms.stories.ts | 15 ++++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/libs/components/src/async-actions/bit-submit.directive.ts b/libs/components/src/async-actions/bit-submit.directive.ts index 334696576b5..1fee5283bda 100644 --- a/libs/components/src/async-actions/bit-submit.directive.ts +++ b/libs/components/src/async-actions/bit-submit.directive.ts @@ -18,7 +18,6 @@ export class BitSubmitDirective implements OnInit, OnDestroy { private _disabled$ = new BehaviorSubject(false); @Input("bitSubmit") protected handler: FunctionReturningAwaitable; - @Input("disableFormOnLoading") protected disableFormOnLoading = false; readonly loading$ = this._loading$.asObservable(); readonly disabled$ = this._disabled$.asObservable(); diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts index ce0528cc04f..b492032df12 100644 --- a/libs/components/src/async-actions/in-forms.stories.ts +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +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"; @@ -18,7 +18,7 @@ import { BitSubmitDirective } from "./bit-submit.directive"; import { BitFormButtonDirective } from "./form-button.directive"; const template = ` -
+ Name @@ -45,8 +45,6 @@ class PromiseExampleComponent { email: ["", [Validators.required, Validators.email]], }); - @Input() disableFormOnLoading: boolean; - constructor(private formBuilder: FormBuilder) {} submit = async () => { @@ -78,8 +76,6 @@ class ObservableExampleComponent { email: ["", [Validators.required, Validators.email]], }); - @Input() disableFormOnLoading: boolean; - constructor(private formBuilder: FormBuilder) {} submit = () => { @@ -136,21 +132,18 @@ export default { ], }), ], - args: { - disableFormOnLoading: false, - }, } as Meta; const PromiseTemplate: Story = (args: PromiseExampleComponent) => ({ props: args, - template: ``, + template: ``, }); export const UsingPromise = PromiseTemplate.bind({}); const ObservableTemplate: Story = (args: PromiseExampleComponent) => ({ props: args, - template: ``, + template: ``, }); export const UsingObservable = ObservableTemplate.bind({}); From 3a298bd9899fd3d88cce8c46329f79ca13624478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 11 Oct 2022 13:08:48 +0100 Subject: [PATCH 15/22] [EC-377] Transition Policy service into providing observables (#3259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added abstractions for PolicyApiService and PolicyService * Added implementations for PolicyApiService and PolicyService * Updated all references to new PolicyApiService and PolicyService * Deleted old PolicyService abstraction and implementation * Fixed CLI import path for policy.service * Fixed main.background.ts policyApiService dependency for policyService * Ran prettier * Updated policy-api.service with the correct imports * [EC-377] Removed methods from StateService that read policies * [EC-377] Updated policy service getAll method to use observable collection * [EC-377] Added first unit tests for policy service * [EC-377] Added more unit tests for Policy Service * [EC-376] Sorted methods order in PolicyApiService * [EC-376] Removed unused clearCache method from PolicyService * [EC-376] Added upsert method to PolicyService * [EC-376] PolicyApiService putPolicy method now upserts data to PolicyService * [EC-377] Removed tests for deleted clearCache method * [EC-377] Added unit test for PolicyService.upsert * [EC-377] Updated references to state service observables * [EC-377] Removed getAll method from PolicyService and refactored components to use observable collection * [EC-377] Updated components to use concatMap instead of async subscribe * [EC-377] Removed getPolicyForOrganization from policyApiService * [EC-377] Updated policyAppliesToUser to return observable collection * [EC-377] Changed policyService.policyAppliesToUser to return observable * [EC-377] Fixed browser settings.component.ts getting vault timeout * Updated people.component.ts to get ResetPassword policy through a subscription * [EC-377] Changed passwordGenerationService.getOptions to return observable * [EC-377] Fixed CLI generate.command.ts getting enforcePasswordGeneratorPoliciesOnOptions * [EC-377] Fixed eslint errors on rxjs * [EC-377] Reverted changes on passwordGeneration.service and vaultTimeout.service * [EC-377] Removed eslint disable on web/vault/add-edit-component * [EC-377] Changed AccountData.policies to TemporaryDataEncryption * [EC-377] Updated import.component to be reactive to policyAppliesToUser$ * [EC-377] Updated importBlockedByPolicy$ * [EC-377] Fixed missing rename * [EC-377] Updated policyService.masterPasswordPolicyOptions to return observable * [EC-377] Fixed vaultTimeout imports from merge * [EC-377] Reverted call to passwordGenerationService.getOptions * [EC-377] Reverted call to enforcePasswordGeneratorPoliciesOnOptions * [EC-377] Removed unneeded ngOnDestroy * Apply suggestions from code review Co-authored-by: Oscar Hinton * [EC-377] Fixed login.component.ts and register.component.ts * [EC-377] Updated PolicyService to update vaultTimeout * [EC-377] Updated PolicyService dependencies * [EC-377] Renamed policyAppliesToUser to policyAppliesToActiveUser * [EC-377] VaultTimeoutSettings service now gets the vault timeout directly instead of using observables * [EC-377] Fixed unit tests by removing unneeded vaultTimeoutSettingsService * [EC-377] Set getDecryptedPolicies and setDecryptedPolicies as deprecated * [EC-377] Set PolicyService.getAll as deprecated and updated to use prototype.hasOwnProperty * [EC-565] Reverted unintended change to vaultTimeoutSettings that was causing a bug to not display the correct vault timeout * [EC-377] Removed unneeded destroy$ from preferences.component.ts * [EC-377] Fixed policy.service.ts import of OrganizationService Co-authored-by: Oscar Hinton Co-authored-by: mimartin12 <77340197+mimartin12@users.noreply.github.com> --- .../src/background/commands.background.ts | 2 +- .../src/background/contextMenus.background.ts | 2 +- .../src/background/notification.background.ts | 4 +- apps/cli/src/commands/export.command.ts | 5 +- .../src/app/accounts/login/login.component.ts | 21 +- .../src/app/accounts/register.component.ts | 21 +- .../trial-initiation.component.spec.ts | 22 +- .../trial-initiation.component.ts | 23 +- apps/web/src/app/core/event.service.ts | 23 +- .../organizations/manage/people.component.ts | 106 +++--- .../manage/reset-password.component.ts | 31 +- .../import-export/org-import.component.ts | 1 - .../emergency-access-takeover.component.ts | 22 +- .../settings/organization-plans.component.ts | 33 +- .../settings/two-factor-setup.component.ts | 26 +- .../tools/import-export/import.component.html | 12 +- .../tools/import-export/import.component.ts | 20 +- apps/web/src/app/vault/add-edit.component.ts | 10 +- .../organization-options.component.ts | 25 +- .../src/components/add-edit.component.ts | 26 +- .../components/change-password.component.ts | 20 +- .../src/components/export.component.ts | 10 +- .../src/components/send/add-edit.component.ts | 31 +- .../src/components/send/send.component.ts | 18 +- .../settings/vault-timeout-input.component.ts | 52 ++- .../update-temp-password.component.ts | 1 - .../services/vault-filter.service.ts | 8 +- .../spec/services/policy.service.spec.ts | 356 ++++++++++++++++++ .../policy/policy-api.service.abstraction.ts | 2 - .../policy/policy.service.abstraction.ts | 16 +- libs/common/src/abstractions/state.service.ts | 12 + .../services/passwordGeneration.service.ts | 7 +- .../src/services/policy/policy-api.service.ts | 22 +- .../src/services/policy/policy.service.ts | 191 ++++++---- libs/node/src/cli/commands/login.command.ts | 5 +- 35 files changed, 915 insertions(+), 271 deletions(-) create mode 100644 libs/common/spec/services/policy.service.spec.ts diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts index 7303d790ec9..2ad3eafe49c 100644 --- a/apps/browser/src/background/commands.background.ts +++ b/apps/browser/src/background/commands.background.ts @@ -64,7 +64,7 @@ export default class CommandsBackground { } private async generatePasswordToClipboard() { - const options = (await this.passwordGenerationService.getOptions())[0]; + const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; const password = await this.passwordGenerationService.generatePassword(options); this.platformUtilsService.copyToClipboard(password, { window: window }); this.passwordGenerationService.addHistory(password); diff --git a/apps/browser/src/background/contextMenus.background.ts b/apps/browser/src/background/contextMenus.background.ts index 22d9d56bbff..8af66db1f0c 100644 --- a/apps/browser/src/background/contextMenus.background.ts +++ b/apps/browser/src/background/contextMenus.background.ts @@ -66,7 +66,7 @@ export default class ContextMenusBackground { } private async generatePasswordToClipboard() { - const options = (await this.passwordGenerationService.getOptions())[0]; + const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; const password = await this.passwordGenerationService.generatePassword(options); this.platformUtilsService.copyToClipboard(password, { window: window }); this.passwordGenerationService.addHistory(password); diff --git a/apps/browser/src/background/notification.background.ts b/apps/browser/src/background/notification.background.ts index e011f3d6976..5eaec27325f 100644 --- a/apps/browser/src/background/notification.background.ts +++ b/apps/browser/src/background/notification.background.ts @@ -446,6 +446,8 @@ export default class NotificationBackground { } private async allowPersonalOwnership(): Promise { - return !(await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership)); + return !(await firstValueFrom( + this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership) + )); } } diff --git a/apps/cli/src/commands/export.command.ts b/apps/cli/src/commands/export.command.ts index 50446d7e027..4b367463096 100644 --- a/apps/cli/src/commands/export.command.ts +++ b/apps/cli/src/commands/export.command.ts @@ -1,5 +1,6 @@ import * as program from "commander"; import * as inquirer from "inquirer"; +import { firstValueFrom } from "rxjs"; import { ExportFormat, ExportService } from "@bitwarden/common/abstractions/export.service"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; @@ -15,7 +16,9 @@ export class ExportCommand { async run(options: program.OptionValues): Promise { if ( options.organizationid == null && - (await this.policyService.policyAppliesToUser(PolicyType.DisablePersonalVaultExport)) + (await firstValueFrom( + this.policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport) + )) ) { return Response.badRequest( "One or more organization policies prevents you from exporting your personal vault." diff --git a/apps/web/src/app/accounts/login/login.component.ts b/apps/web/src/app/accounts/login/login.component.ts index c27ba536360..02bc781ee17 100644 --- a/apps/web/src/app/accounts/login/login.component.ts +++ b/apps/web/src/app/accounts/login/login.component.ts @@ -1,6 +1,7 @@ -import { Component, NgZone } from "@angular/core"; +import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/components/login.component"; @@ -29,13 +30,14 @@ import { RouterService, StateService } from "../../core"; selector: "app-login", templateUrl: "login.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class LoginComponent extends BaseLoginComponent { +export class LoginComponent extends BaseLoginComponent implements OnInit, OnDestroy { showResetPasswordAutoEnrollWarning = false; enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; policies: ListResponse; showPasswordless = false; + private destroy$ = new Subject(); + constructor( authService: AuthService, router: Router, @@ -128,12 +130,21 @@ export class LoginComponent extends BaseLoginComponent { this.showResetPasswordAutoEnrollWarning = resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; - this.enforcedPasswordPolicyOptions = - await this.policyService.getMasterPasswordPolicyOptions(policyList); + this.policyService + .masterPasswordPolicyOptions$(policyList) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions; + }); } } } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async goAfterLogIn() { const masterPassword = this.formGroup.value.masterPassword; diff --git a/apps/web/src/app/accounts/register.component.ts b/apps/web/src/app/accounts/register.component.ts index 4e700ca4d40..2d1eb6d5e53 100644 --- a/apps/web/src/app/accounts/register.component.ts +++ b/apps/web/src/app/accounts/register.component.ts @@ -1,6 +1,7 @@ -import { Component } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/components/register.component"; @@ -27,14 +28,14 @@ import { RouterService } from "../core"; selector: "app-register", templateUrl: "register.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class RegisterComponent extends BaseRegisterComponent { +export class RegisterComponent extends BaseRegisterComponent implements OnInit, OnDestroy { email = ""; showCreateOrgMessage = false; layout = ""; enforcedPolicyOptions: MasterPasswordPolicyOptions; private policies: Policy[]; + private destroy$ = new Subject(); constructor( formValidationErrorService: FormValidationErrorsService, @@ -130,11 +131,19 @@ export class RegisterComponent extends BaseRegisterComponent { } if (this.policies != null) { - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions( - this.policies - ); + this.policyService + .masterPasswordPolicyOptions$(this.policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; + }); } await super.ngOnInit(); } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts index fd3552ebafd..c69a1bf1f0a 100644 --- a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts +++ b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts @@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; // eslint-disable-next-line no-restricted-imports import { Substitute } from "@fluffy-spoon/substitute"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/pipes/i18n.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -47,7 +47,7 @@ describe("TrialInitiationComponent", () => { }; policyServiceMock = { - getMasterPasswordPolicyOptions: jest.fn(), + masterPasswordPolicyOptions$: jest.fn(), }; TestBed.configureTestingModule({ @@ -145,14 +145,16 @@ describe("TrialInitiationComponent", () => { }, ], }); - policyServiceMock.getMasterPasswordPolicyOptions.mockReturnValueOnce({ - minComplexity: 4, - minLength: 10, - requireLower: null, - requireNumbers: null, - requireSpecial: null, - requireUpper: null, - } as MasterPasswordPolicyOptions); + policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue( + of({ + minComplexity: 4, + minLength: 10, + requireLower: null, + requireNumbers: null, + requireSpecial: null, + requireUpper: null, + } as MasterPasswordPolicyOptions) + ); // Need to recreate component with new service mocks fixture = TestBed.createComponent(TrialInitiationComponent); diff --git a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts index 00bbc6e236f..d8b2fc0873d 100644 --- a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts @@ -1,9 +1,9 @@ import { StepperSelectionEvent } from "@angular/cdk/stepper"; import { TitleCasePipe } from "@angular/common"; -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { UntypedFormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { first } from "rxjs"; +import { first, Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; @@ -24,8 +24,7 @@ import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.co selector: "app-trial", templateUrl: "trial-initiation.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class TrialInitiationComponent implements OnInit { +export class TrialInitiationComponent implements OnInit, OnDestroy { email = ""; org = ""; orgInfoSubLabel = ""; @@ -63,6 +62,8 @@ export class TrialInitiationComponent implements OnInit { } } + private destroy$ = new Subject(); + constructor( private route: ActivatedRoute, protected router: Router, @@ -140,12 +141,20 @@ export class TrialInitiationComponent implements OnInit { } if (this.policies != null) { - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions( - this.policies - ); + this.policyService + .masterPasswordPolicyOptions$(this.policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; + }); } } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + stepSelectionChange(event: StepperSelectionEvent) { // Set org info sub label if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") { diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index 1a17998747e..6f77079cd99 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -1,16 +1,32 @@ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy, OnInit } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { DeviceType } from "@bitwarden/common/enums/deviceType"; import { EventType } from "@bitwarden/common/enums/eventType"; import { PolicyType } from "@bitwarden/common/enums/policyType"; +import { Policy } from "@bitwarden/common/models/domain/policy"; import { EventResponse } from "@bitwarden/common/models/response/eventResponse"; @Injectable() -export class EventService { +export class EventService implements OnInit, OnDestroy { + private destroy$ = new Subject(); + private policies: Policy[]; + constructor(private i18nService: I18nService, private policyService: PolicyService) {} + ngOnInit(): void { + this.policyService.policies$.pipe(takeUntil(this.destroy$)).subscribe((policies) => { + this.policies = policies; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + getDefaultDateFilters() { const d = new Date(); const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59); @@ -326,8 +342,7 @@ export class EventService { case EventType.Policy_Updated: { msg = this.i18nService.t("modifiedPolicyId", this.formatPolicyId(ev)); - const policies = await this.policyService.getAll(); - const policy = policies.filter((p) => p.id === ev.policyId)[0]; + const policy = this.policies.filter((p) => p.id === ev.policyId)[0]; let p1 = this.getShortId(ev.policyId); if (policy != null) { p1 = PolicyType[policy.type]; diff --git a/apps/web/src/app/organizations/manage/people.component.ts b/apps/web/src/app/organizations/manage/people.component.ts index 94e9493431a..cf426ef7aa5 100644 --- a/apps/web/src/app/organizations/manage/people.component.ts +++ b/apps/web/src/app/organizations/manage/people.component.ts @@ -1,6 +1,6 @@ -import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { first } from "rxjs/operators"; +import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, concatMap, Subject, takeUntil } from "rxjs"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -12,7 +12,6 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; @@ -43,10 +42,9 @@ import { UserGroupsComponent } from "./user-groups.component"; selector: "app-org-people", templateUrl: "people.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil export class PeopleComponent extends BasePeopleComponent - implements OnInit + implements OnInit, OnDestroy { @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) @@ -77,6 +75,8 @@ export class PeopleComponent orgResetPasswordPolicyEnabled = false; callingUserType: OrganizationUserType = null; + private destroy$ = new Subject(); + constructor( apiService: ApiService, private route: ActivatedRoute, @@ -84,10 +84,8 @@ export class PeopleComponent modalService: ModalService, platformUtilsService: PlatformUtilsService, cryptoService: CryptoService, - private router: Router, searchService: SearchService, validationService: ValidationService, - private policyApiService: PolicyApiServiceAbstraction, private policyService: PolicyService, logService: LogService, searchPipe: SearchPipe, @@ -113,53 +111,63 @@ export class PeopleComponent } async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.parent.params.subscribe(async (params) => { - this.organizationId = params.organizationId; - const organization = await this.organizationService.get(this.organizationId); - this.accessEvents = organization.useEvents; - this.accessGroups = organization.useGroups; - this.canResetPassword = organization.canManageUsersPassword; - this.orgUseResetPassword = organization.useResetPassword; - this.callingUserType = organization.type; - this.orgHasKeys = organization.hasPublicAndPrivateKeys; + combineLatest([this.route.params, this.route.queryParams, this.policyService.policies$]) + .pipe( + concatMap(async ([params, qParams, policies]) => { + this.organizationId = params.organizationId; + const organization = await this.organizationService.get(this.organizationId); + this.accessEvents = organization.useEvents; + this.accessGroups = organization.useGroups; + this.canResetPassword = organization.canManageUsersPassword; + this.orgUseResetPassword = organization.useResetPassword; + this.callingUserType = organization.type; + this.orgHasKeys = organization.hasPublicAndPrivateKeys; - // Backfill pub/priv key if necessary - if (this.canResetPassword && !this.orgHasKeys) { - const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); - const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); - const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); - const response = await this.organizationApiService.updateKeys(this.organizationId, request); - if (response != null) { - this.orgHasKeys = response.publicKey != null && response.privateKey != null; - await this.syncService.fullSync(true); // Replace oganizations with new data - } else { - throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); - } - } - - await this.load(); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchText = qParams.search; - if (qParams.viewEvents != null) { - const user = this.users.filter((u) => u.id === qParams.viewEvents); - if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { - this.events(user[0]); + // Backfill pub/priv key if necessary + if (this.canResetPassword && !this.orgHasKeys) { + const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); + const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); + const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); + const response = await this.organizationApiService.updateKeys( + this.organizationId, + request + ); + if (response != null) { + this.orgHasKeys = response.publicKey != null && response.privateKey != null; + await this.syncService.fullSync(true); // Replace oganizations with new data + } else { + throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); + } } - } - }); - }); + + const resetPasswordPolicy = policies + .filter((policy) => policy.type === PolicyType.ResetPassword) + .find((p) => p.organizationId === this.organizationId); + this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled; + + await this.load(); + + this.searchText = qParams.search; + if (qParams.viewEvents != null) { + const user = this.users.filter((u) => u.id === qParams.viewEvents); + if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { + this.events(user[0]); + } + } + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } async load() { - const resetPasswordPolicy = await this.policyApiService.getPolicyForOrganization( - PolicyType.ResetPassword, - this.organizationId - ); - this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled; super.load(); + await super.load(); } getUsers(): Promise> { diff --git a/apps/web/src/app/organizations/manage/reset-password.component.ts b/apps/web/src/app/organizations/manage/reset-password.component.ts index d37148a25eb..97e9466e80e 100644 --- a/apps/web/src/app/organizations/manage/reset-password.component.ts +++ b/apps/web/src/app/organizations/manage/reset-password.component.ts @@ -1,4 +1,13 @@ -import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, +} from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; import zxcvbn from "zxcvbn"; import { PasswordStrengthComponent } from "@bitwarden/angular/shared/components/password-strength/password-strength.component"; @@ -18,7 +27,7 @@ import { OrganizationUserResetPasswordRequest } from "@bitwarden/common/models/r selector: "app-reset-password", templateUrl: "reset-password.component.html", }) -export class ResetPasswordComponent implements OnInit { +export class ResetPasswordComponent implements OnInit, OnDestroy { @Input() name: string; @Input() email: string; @Input() id: string; @@ -32,6 +41,8 @@ export class ResetPasswordComponent implements OnInit { passwordStrengthResult: zxcvbn.ZXCVBNResult; formPromise: Promise; + private destroy$ = new Subject(); + constructor( private apiService: ApiService, private i18nService: I18nService, @@ -43,8 +54,18 @@ export class ResetPasswordComponent implements OnInit { ) {} async ngOnInit() { - // Get Enforced Policy Options - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions(); + this.policyService + .masterPasswordPolicyOptions$() + .pipe(takeUntil(this.destroy$)) + .subscribe( + (enforcedPasswordPolicyOptions) => + (this.enforcedPolicyOptions = enforcedPasswordPolicyOptions) + ); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } get loggedOutWarningName() { @@ -52,7 +73,7 @@ export class ResetPasswordComponent implements OnInit { } async generatePassword() { - const options = (await this.passwordGenerationService.getOptions())[0]; + const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; this.newPassword = await this.passwordGenerationService.generatePassword(options); this.passwordStrengthComponent.updatePasswordStrength(this.newPassword); } diff --git a/apps/web/src/app/organizations/tools/import-export/org-import.component.ts b/apps/web/src/app/organizations/tools/import-export/org-import.component.ts index 69506b226a3..81a16a29680 100644 --- a/apps/web/src/app/organizations/tools/import-export/org-import.component.ts +++ b/apps/web/src/app/organizations/tools/import-export/org-import.component.ts @@ -47,7 +47,6 @@ export class OrganizationImportComponent extends ImportComponent { this.organizationId = params.organizationId; this.successNavigate = ["organizations", this.organizationId, "vault"]; await super.ngOnInit(); - this.importBlockedByPolicy = false; }); const organization = await this.organizationService.get(this.organizationId); this.organizationName = organization.name; diff --git a/apps/web/src/app/settings/emergency-access-takeover.component.ts b/apps/web/src/app/settings/emergency-access-takeover.component.ts index 5731dc8b891..27a9484f4de 100644 --- a/apps/web/src/app/settings/emergency-access-takeover.component.ts +++ b/apps/web/src/app/settings/emergency-access-takeover.component.ts @@ -1,4 +1,5 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { takeUntil } from "rxjs"; import { ChangePasswordComponent } from "@bitwarden/angular/components/change-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -21,7 +22,11 @@ import { PolicyResponse } from "@bitwarden/common/models/response/policyResponse selector: "emergency-access-takeover", templateUrl: "emergency-access-takeover.component.html", }) -export class EmergencyAccessTakeoverComponent extends ChangePasswordComponent implements OnInit { +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class EmergencyAccessTakeoverComponent + extends ChangePasswordComponent + implements OnInit, OnDestroy +{ @Output() onDone = new EventEmitter(); @Input() emergencyAccessId: string; @Input() name: string; @@ -59,12 +64,19 @@ export class EmergencyAccessTakeoverComponent extends ChangePasswordComponent im const policies = response.data.map( (policyResponse: PolicyResponse) => new Policy(new PolicyData(policyResponse)) ); - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions( - policies - ); + + this.policyService + .masterPasswordPolicyOptions$(policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPolicyOptions) => (this.enforcedPolicyOptions = enforcedPolicyOptions)); } } + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + ngOnDestroy(): void { + super.ngOnDestroy(); + } + async submit() { if (!(await this.strongPassword())) { return; diff --git a/apps/web/src/app/settings/organization-plans.component.ts b/apps/web/src/app/settings/organization-plans.component.ts index 485afd12b15..934803b3fb1 100644 --- a/apps/web/src/app/settings/organization-plans.component.ts +++ b/apps/web/src/app/settings/organization-plans.component.ts @@ -1,6 +1,15 @@ -import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, +} from "@angular/core"; import { UntypedFormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; @@ -35,7 +44,7 @@ interface OnSuccessArgs { selector: "app-organization-plans", templateUrl: "organization-plans.component.html", }) -export class OrganizationPlansComponent implements OnInit { +export class OrganizationPlansComponent implements OnInit, OnDestroy { @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent; @@ -73,6 +82,8 @@ export class OrganizationPlansComponent implements OnInit { plans: PlanResponse[]; + private destroy$ = new Subject(); + constructor( private apiService: ApiService, private i18nService: I18nService, @@ -114,9 +125,21 @@ export class OrganizationPlansComponent implements OnInit { this.formGroup.controls.billingEmail.addValidators(Validators.required); } + this.policyService + .policyAppliesToActiveUser$(PolicyType.SingleOrg) + .pipe(takeUntil(this.destroy$)) + .subscribe((policyAppliesToActiveUser) => { + this.singleOrgPolicyBlock = policyAppliesToActiveUser; + }); + this.loading = false; } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + get createOrganization() { return this.organizationId == null; } @@ -288,8 +311,6 @@ export class OrganizationPlansComponent implements OnInit { } async submit() { - this.singleOrgPolicyBlock = await this.userHasBlockingSingleOrgPolicy(); - if (this.singleOrgPolicyBlock) { return; } @@ -353,10 +374,6 @@ export class OrganizationPlansComponent implements OnInit { } } - private async userHasBlockingSingleOrgPolicy() { - return this.policyService.policyAppliesToUser(PolicyType.SingleOrg); - } - private async updateOrganization(orgId: string) { const request = new OrganizationUpgradeRequest(); request.businessName = this.formGroup.controls.businessOwned.value diff --git a/apps/web/src/app/settings/two-factor-setup.component.ts b/apps/web/src/app/settings/two-factor-setup.component.ts index 13e3cbecea8..994262548bc 100644 --- a/apps/web/src/app/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/settings/two-factor-setup.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, Type, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, OnDestroy, OnInit, Type, ViewChild, ViewContainerRef } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -25,8 +26,7 @@ import { TwoFactorYubiKeyComponent } from "./two-factor-yubikey.component"; selector: "app-two-factor-setup", templateUrl: "two-factor-setup.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class TwoFactorSetupComponent implements OnInit { +export class TwoFactorSetupComponent implements OnInit, OnDestroy { @ViewChild("recoveryTemplate", { read: ViewContainerRef, static: true }) recoveryModalRef: ViewContainerRef; @ViewChild("authenticatorTemplate", { read: ViewContainerRef, static: true }) @@ -49,6 +49,9 @@ export class TwoFactorSetupComponent implements OnInit { modal: ModalRef; formPromise: Promise; + private destroy$ = new Subject(); + private twoFactorAuthPolicyAppliesToActiveUser: boolean; + constructor( protected apiService: ApiService, protected modalService: ModalService, @@ -93,9 +96,22 @@ export class TwoFactorSetupComponent implements OnInit { } this.providers.sort((a: any, b: any) => a.sort - b.sort); + + this.policyService + .policyAppliesToActiveUser$(PolicyType.TwoFactorAuthentication) + .pipe(takeUntil(this.destroy$)) + .subscribe((policyAppliesToActiveUser) => { + this.twoFactorAuthPolicyAppliesToActiveUser = policyAppliesToActiveUser; + }); + await this.load(); } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async load() { this.loading = true; const providerList = await this.getTwoFactorProviders(); @@ -203,9 +219,7 @@ export class TwoFactorSetupComponent implements OnInit { private async evaluatePolicies() { if (this.organizationId == null && this.providers.filter((p) => p.enabled).length === 1) { - this.showPolicyWarning = await this.policyService.policyAppliesToUser( - PolicyType.TwoFactorAuthentication - ); + this.showPolicyWarning = this.twoFactorAuthPolicyAppliesToActiveUser; } else { this.showPolicyWarning = false; } diff --git a/apps/web/src/app/tools/import-export/import.component.html b/apps/web/src/app/tools/import-export/import.component.html index 26d820b5189..ba2eb69925c 100644 --- a/apps/web/src/app/tools/import-export/import.component.html +++ b/apps/web/src/app/tools/import-export/import.component.html @@ -1,7 +1,7 @@ - + {{ "personalOwnershipPolicyInEffectImports" | i18n }} @@ -14,7 +14,7 @@ name="Format" [(ngModel)]="format" class="form-control" - [disabled]="importBlockedByPolicy" + [disabled]="importBlockedByPolicy$ | async" required > @@ -296,7 +296,7 @@ id="file" class="form-control-file" name="file" - [disabled]="importBlockedByPolicy" + [disabled]="importBlockedByPolicy$ | async" /> @@ -308,14 +308,14 @@ class="form-control" name="FileContents" [(ngModel)]="fileContents" - [disabled]="importBlockedByPolicy" + [disabled]="importBlockedByPolicy$ | async" > + + `, +}); + +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 = (args: MultiSelectComponent) => ({ + props: { + ...args, + onItemsConfirmed: actionsData.onItemsConfirmed, + }, + template: ` + + + `, +}); + +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", +}; diff --git a/libs/components/src/input/input.directive.ts b/libs/components/src/input/input.directive.ts index c47bb837660..619b0229057 100644 --- a/libs/components/src/input/input.directive.ts +++ b/libs/components/src/input/input.directive.ts @@ -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; } diff --git a/libs/components/src/multi-select/models/select-item-view.ts b/libs/components/src/multi-select/models/select-item-view.ts new file mode 100644 index 00000000000..45cf47d73f4 --- /dev/null +++ b/libs/components/src/multi-select/models/select-item-view.ts @@ -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 +}; diff --git a/libs/components/src/multi-select/multi-select.component.html b/libs/components/src/multi-select/multi-select.component.html new file mode 100644 index 00000000000..fc355eeb4f0 --- /dev/null +++ b/libs/components/src/multi-select/multi-select.component.html @@ -0,0 +1,55 @@ + + + + + + + + +
+
+ +
+
+ +
+
+ {{ item.listName }} +
+
+
+
diff --git a/libs/components/src/multi-select/multi-select.component.ts b/libs/components/src/multi-select/multi-select.component.ts new file mode 100644 index 00000000000..81b815ed0de --- /dev/null +++ b/libs/components/src/multi-select/multi-select.component.ts @@ -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(); + + 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]]; + } +} diff --git a/libs/components/src/multi-select/multi-select.module.ts b/libs/components/src/multi-select/multi-select.module.ts new file mode 100644 index 00000000000..88de53b5481 --- /dev/null +++ b/libs/components/src/multi-select/multi-select.module.ts @@ -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 {} diff --git a/libs/components/src/multi-select/scss/bw.theme.scss b/libs/components/src/multi-select/scss/bw.theme.scss new file mode 100644 index 00000000000..85e55fc0e58 --- /dev/null +++ b/libs/components/src/multi-select/scss/bw.theme.scss @@ -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; + } +} diff --git a/libs/components/src/styles.scss b/libs/components/src/styles.scss index 18ee0ada983..510b7ac88ae 100644 --- a/libs/components/src/styles.scss +++ b/libs/components/src/styles.scss @@ -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"; diff --git a/package-lock.json b/package-lock.json index 059f49ffcc3..129ac3a1195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@koa/router": "^10.1.1", "@microsoft/signalr": "^6.0.7", "@microsoft/signalr-protocol-msgpack": "^6.0.7", + "@ng-select/ng-select": "^9.0.2", "big-integer": "^1.6.51", "bootstrap": "4.6.0", "braintree-web-drop-in": "^1.33.1", @@ -5216,6 +5217,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@ng-select/ng-select": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-9.0.2.tgz", + "integrity": "sha512-xdNiz/kgkMWYW1qFtk/337xDk/cmfEbSVtTFxWIM2OnIX1XsQOnTlGiBYces1TsMfqS68HjAvljEkj8QIGN2Lg==", + "dependencies": { + "tslib": "^2.3.1" + }, + "engines": { + "node": ">= 12.20.0", + "npm": ">= 6.0.0" + }, + "peerDependencies": { + "@angular/common": "<15.0.0", + "@angular/core": "<15.0.0", + "@angular/forms": "<15.0.0" + } + }, "node_modules/@ngtools/webpack": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-14.1.0.tgz", @@ -46775,6 +46793,14 @@ "integrity": "sha512-Rscrg0BO4AKqFX2mKd8C68Wh3TkSHXqF2PZp+utVoLV+PTQnGVMwHedtIHBcFoq1Ij3I4yETMgSFSdAR+lp++Q==", "dev": true }, + "@ng-select/ng-select": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-9.0.2.tgz", + "integrity": "sha512-xdNiz/kgkMWYW1qFtk/337xDk/cmfEbSVtTFxWIM2OnIX1XsQOnTlGiBYces1TsMfqS68HjAvljEkj8QIGN2Lg==", + "requires": { + "tslib": "^2.3.1" + } + }, "@ngtools/webpack": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-14.1.0.tgz", @@ -47213,7 +47239,7 @@ "postcss": "^7.0.36", "postcss-loader": "^4.2.0", "raw-loader": "^4.0.2", - "react": "^18.0.0", + "react": "^16.14.0", "react-dom": "^16.14.0", "read-pkg-up": "^7.0.1", "regenerator-runtime": "^0.13.7", diff --git a/package.json b/package.json index 0260152ce47..2325d25e1ef 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "@koa/router": "^10.1.1", "@microsoft/signalr": "^6.0.7", "@microsoft/signalr-protocol-msgpack": "^6.0.7", + "@ng-select/ng-select": "^9.0.2", "big-integer": "^1.6.51", "bootstrap": "4.6.0", "braintree-web-drop-in": "^1.33.1", From c8e4f88379b074c125555a849a09c2d8f709d476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ch=C4=99ci=C5=84ski?= Date: Tue, 11 Oct 2022 16:35:25 +0200 Subject: [PATCH 17/22] [DEVOPS-1014] Fix desktop autobump workflow (#3736) * Fix desktop autobump workflow * Fix desktop autobump workflow accoring to vince suggestions in mobile * Update ubuntu version --- .github/workflows/version-auto-bump.yml | 30 ++++++++++++------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 4c1b0f6e8f1..29118a0a9fc 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -12,7 +12,7 @@ defaults: jobs: setup: name: "Setup" - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: version_number: ${{ steps.version.outputs.new-version }} if: contains(github.event.release.tag, 'desktop') @@ -20,22 +20,23 @@ jobs: - name: Checkout Branch uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 - - name: Get version to bump + - name: Calculate bumped version id: version env: - RELEASE_TAG: ${{ github.event.release.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | + CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\1/') + CURR_PATCH=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\2/') + echo "Current Patch: $CURR_PATCH" - CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/desktop-v([0-9]{4}\.[0-9]\.)([0-9])/\1/') - CURR_VER=$(echo $RELEASE_TAG | sed -r 's/desktop-v([0-9]{4}\.[0-9]\.)([0-9])/\2/') - echo $CURR_VER - ((CURR_VER++)) - NEW_VER=$CURR_MAJOR$CURR_VER + NEW_PATCH=$((CURR_PATCH++)) + NEW_VER=$CURR_MAJOR.$NEW_PATCH + echo "New Version: $NEW_VER" echo "::set-output name=new-version::$NEW_VER" trigger_version_bump: name: "Trigger desktop version bump workflow" - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - setup steps: @@ -46,13 +47,10 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRET: "github-pat-bitwarden-devops-bot-repo-scope" - run: | - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $SECRET --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$SECRET::$VALUE" + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "github-pat-bitwarden-devops-bot-repo-scope" - name: Call GitHub API to trigger workflow bump env: From a027ee5a08476b1ce5cf2dd76fc4ac043dde9ff9 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Tue, 11 Oct 2022 10:46:36 -0400 Subject: [PATCH 18/22] DEVOPS-915 - Automate Staged Rollouts for Desktop (#3704) --- .github/workflows/build-web.yml | 6 +- .github/workflows/release-desktop.yml | 71 ++++++------ .github/workflows/staged-rollout-desktop.yml | 114 ++++++++++++++++++- 3 files changed, 145 insertions(+), 46 deletions(-) diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 675f5bbc55a..85b6bdf158c 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -370,8 +370,8 @@ jobs: - cloc - setup - build-artifacts - - build-containers - build-commercial-selfhost-image + - build-containers - crowdin-push steps: - name: Check if any job failed @@ -381,7 +381,7 @@ jobs: SETUP_STATUS: ${{ needs.setup.result }} ARTIFACT_STATUS: ${{ needs.build-artifacts.result }} BUILD_SELFHOST_STATUS: ${{ needs.build-commercial-selfhost-image.result }} - BUILD_QA_STATUS: ${{ needs.build-qa.result }} + BUILD_CONTAINERS_STATUS: ${{ needs.build-containers.result }} CROWDIN_PUSH_STATUS: ${{ needs.crowdin-push.result }} run: | if [ "$CLOC_STATUS" = "failure" ]; then @@ -392,7 +392,7 @@ jobs: exit 1 elif [ "$BUILD_SELFHOST_STATUS" = "failure" ]; then exit 1 - elif [ "$BUILD_QA_STATUS" = "failure" ]; then + elif [ "$BUILD_CONTAINERS_STATUS" = "failure" ]; then exit 1 elif [ "$CROWDIN_PUSH_STATUS" = "failure" ]; then exit 1 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 85e9122b335..247b59ea43d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -13,13 +13,18 @@ on: - Initial Release - Redeploy - Dry Run + rollout_percentage: + description: 'Staged Rollout Percentage' + required: true + default: '10' + type: string snap_publish: - description: 'Publish to snap store' + description: 'Publish to Snap store' required: true default: true type: boolean choco_publish: - description: 'Publish to chocolatey store' + description: 'Publish to Chocolatey store' required: true default: true type: boolean @@ -93,23 +98,16 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRETS: | - aws-electron-access-id, + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "aws-electron-access-id, aws-electron-access-key, aws-electron-bucket-name, r2-electron-access-id, r2-electron-access-key, r2-electron-bucket-name, - cf-prod-account - run: | - for i in ${SECRETS//,/ } - do - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $i --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$i::$VALUE" - done + cf-prod-account" - name: Download all artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -135,6 +133,15 @@ jobs: working-directory: apps/desktop/artifacts run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive + - name: Set staged rollout percentage + env: + RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }} + ROLLOUT_PCT: ${{ github.event.inputs.rollout_percentage }} + run: | + echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}.yml + echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-linux.yml + echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-mac.yml + - name: Publish artifacts to S3 if: ${{ github.event.inputs.release_type != 'Dry Run' }} env: @@ -164,8 +171,8 @@ jobs: --quiet \ --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com - - name: Create release - uses: ncipollo/release-action@95215a3cb6e6a1908b3c44e00b4fdb15548b1e09 # v2.8.5 + - name: Create Release + uses: ncipollo/release-action@95215a3cb6e6a1908b3c44e00b4fdb15548b1e09 if: ${{ steps.release-channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }} env: PKG_VERSION: ${{ steps.version.outputs.version }} @@ -236,17 +243,10 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRETS: | - snapcraft-store-token - run: | - for i in ${SECRETS//,/ } - do - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $i --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$i::$VALUE" - done + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "snapcraft-store-token" - name: Install Snap uses: samuelmeuli/action-snapcraft@10d7d0a84d9d86098b19f872257df314b0bd8e2d # v1.2.0 @@ -293,7 +293,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout Repo - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f # v2.3.4 + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f - name: Print Environment run: | @@ -307,17 +307,10 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRETS: | - cli-choco-api-key - run: | - for i in ${SECRETS//,/ } - do - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $i --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$i::$VALUE" - done + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "cli-choco-api-key" - name: Setup Chocolatey shell: pwsh diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml index 14f2b8e972b..bf6a21f286f 100644 --- a/.github/workflows/staged-rollout-desktop.yml +++ b/.github/workflows/staged-rollout-desktop.yml @@ -3,15 +3,121 @@ name: Staged Rollout Desktop on: workflow_dispatch: + inputs: + rollout_percentage: + description: 'Staged Rollout Percentage' + required: true + default: '10' + type: string defaults: run: shell: bash jobs: - setup: - name: Stub + rollout: + name: Update Rollout Percentage runs-on: ubuntu-22.04 + outputs: + release-version: ${{ steps.version.outputs.version }} + release-channel: ${{ steps.release-channel.outputs.channel }} steps: - - name: TEST - run: exit 0 + - name: Login to Azure + uses: Azure/login@ec3c14589bd3e9312b3cc8c41e6860e258df9010 + with: + creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + + - name: Retrieve secrets + id: retrieve-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "aws-electron-access-id, + aws-electron-access-key, + aws-electron-bucket-name, + r2-electron-access-id, + r2-electron-access-key, + r2-electron-bucket-name, + cf-prod-account" + + - name: Download channel update info files from S3 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} + AWS_DEFAULT_REGION: 'us-west-2' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} + run: | + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest.yml . \ + --quiet + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-linux.yml . \ + --quiet + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-mac.yml . \ + --quiet + + - name: Download channel update info files from R2 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + run: | + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest.yml . \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-linux.yml . \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-mac.yml . \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + + - name: Check new rollout percentage + env: + NEW_PCT: ${{ github.event.inputs.rollout_percentage }} + run: | + CURRENT_PCT=$(sed -r -n "s/stagingPercentage:\s([0-9]+)/\1/p" latest.yml) + echo "Current percentage: ${CURRENT_PCT}" + echo "New percentage: ${NEW_PCT}" + echo + if [ "$NEW_PCT" -le "$CURRENT_PCT" ]; then + echo "New percentage (${NEW_PCT}) must be higher than current percentage (${CURRENT_PCT})!" + echo + echo "If you want to pull a staged release because it hasn’t gone well, you must increment the version \ + number higher than your broken release. Because some of your users will be on the broken 1.0.1, \ + releasing a new 1.0.1 would result in them staying on a broken version.” + exit 1 + fi + + - name: Set staged rollout percentage + env: + ROLLOUT_PCT: ${{ github.event.inputs.rollout_percentage }} + run: | + sed -i -r "/stagingPercentage/s/[0-9]+/${ROLLOUT_PCT}/" latest.yml + sed -i -r "/stagingPercentage/s/[0-9]+/${ROLLOUT_PCT}/" latest-linux.yml + sed -i -r "/stagingPercentage/s/[0-9]+/${ROLLOUT_PCT}/" latest-mac.yml + + - name: Publish channel update info files to S3 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} + AWS_DEFAULT_REGION: 'us-west-2' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --include "latest*.yml" \ + --acl "public-read" \ + --quiet + + - name: Publish channel update info files to R2 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --include "latest*.yml" \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com From 4bfe44d30300867e5b527d4b7cfc4eec6cd1812b Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 11 Oct 2022 12:24:33 -0400 Subject: [PATCH 19/22] PS 1569 update on command listener (#3647) * Add windows to platform utils service Note, this will result in conflicts with several in-flight PRs, but is necessary for following commits. * Add necessary background service factories * Simplify autofill command * Remove noop event service --- .../browser/src/background/main.background.ts | 3 +- .../autofill-service.factory.ts | 37 +++++ .../cipher-service.factory.ts | 2 +- .../event-service.factory.ts | 40 +++++ .../platform-utils-service.factory.ts | 3 +- .../service_factories/totp-service.factory.ts | 31 ++++ .../src/listeners/onCommandListener.ts | 156 +++++------------- apps/browser/src/services/autofill.service.ts | 8 +- .../browserPlatformUtils.service.spec.ts | 2 +- .../services/browserPlatformUtils.service.ts | 19 ++- libs/common/src/services/noopEvent.service.ts | 24 --- 11 files changed, 170 insertions(+), 155 deletions(-) create mode 100644 apps/browser/src/background/service_factories/autofill-service.factory.ts create mode 100644 apps/browser/src/background/service_factories/event-service.factory.ts create mode 100644 apps/browser/src/background/service_factories/totp-service.factory.ts delete mode 100644 libs/common/src/services/noopEvent.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cf83b6e78d1..56c81ec5add 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -247,7 +247,8 @@ export default class MainBackground { return promise.then((result) => result.response === "unlocked"); } - } + }, + window ); this.i18nService = new I18nService(BrowserApi.getUILanguage(window)); this.encryptService = new EncryptService(this.cryptoFunctionService, this.logService, true); diff --git a/apps/browser/src/background/service_factories/autofill-service.factory.ts b/apps/browser/src/background/service_factories/autofill-service.factory.ts new file mode 100644 index 00000000000..1d67a2735be --- /dev/null +++ b/apps/browser/src/background/service_factories/autofill-service.factory.ts @@ -0,0 +1,37 @@ +import { AutofillService as AbstractAutoFillService } from "../../services/abstractions/autofill.service"; +import AutofillService from "../../services/autofill.service"; + +import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; +import { EventServiceInitOptions, eventServiceFactory } from "./event-service.factory"; +import { CachedServices, factory, FactoryOptions } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; +import { totpServiceFacotry, TotpServiceInitOptions } from "./totp-service.factory"; + +type AutoFillServiceOptions = FactoryOptions; + +export type AutoFillServiceInitOptions = AutoFillServiceOptions & + CipherServiceInitOptions & + StateServiceInitOptions & + TotpServiceInitOptions & + EventServiceInitOptions & + LogServiceInitOptions; + +export function autofillServiceFactory( + cache: { autofillService?: AbstractAutoFillService } & CachedServices, + opts: AutoFillServiceInitOptions +): Promise { + return factory( + cache, + "autofillService", + opts, + async () => + new AutofillService( + await cipherServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), + await totpServiceFacotry(cache, opts), + await eventServiceFactory(cache, opts), + await logServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/background/service_factories/cipher-service.factory.ts b/apps/browser/src/background/service_factories/cipher-service.factory.ts index 149ac54fc82..03141f2c84f 100644 --- a/apps/browser/src/background/service_factories/cipher-service.factory.ts +++ b/apps/browser/src/background/service_factories/cipher-service.factory.ts @@ -44,7 +44,7 @@ export function cipherServiceFactory( await apiServiceFactory(cache, opts), await fileUploadServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), - opts.cipherServiceOptions.searchServiceFactory === undefined + opts.cipherServiceOptions?.searchServiceFactory === undefined ? () => cache.searchService : opts.cipherServiceOptions.searchServiceFactory, await logServiceFactory(cache, opts), diff --git a/apps/browser/src/background/service_factories/event-service.factory.ts b/apps/browser/src/background/service_factories/event-service.factory.ts new file mode 100644 index 00000000000..61a82ebeb19 --- /dev/null +++ b/apps/browser/src/background/service_factories/event-service.factory.ts @@ -0,0 +1,40 @@ +import { EventService as AbstractEventService } from "@bitwarden/common/abstractions/event.service"; +import { EventService } from "@bitwarden/common/services/event.service"; + +import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; +import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; +import { + organizationServiceFactory, + OrganizationServiceInitOptions, +} from "./organization-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; + +type EventServiceOptions = FactoryOptions; + +export type EventServiceInitOptions = EventServiceOptions & + ApiServiceInitOptions & + CipherServiceInitOptions & + StateServiceInitOptions & + LogServiceInitOptions & + OrganizationServiceInitOptions; + +export function eventServiceFactory( + cache: { eventService?: AbstractEventService } & CachedServices, + opts: EventServiceInitOptions +): Promise { + return factory( + cache, + "eventService", + opts, + async () => + new EventService( + await apiServiceFactory(cache, opts), + await cipherServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), + await logServiceFactory(cache, opts), + await organizationServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/background/service_factories/platform-utils-service.factory.ts b/apps/browser/src/background/service_factories/platform-utils-service.factory.ts index 6d85f126361..da25e51ce0c 100644 --- a/apps/browser/src/background/service_factories/platform-utils-service.factory.ts +++ b/apps/browser/src/background/service_factories/platform-utils-service.factory.ts @@ -28,7 +28,8 @@ export function platformUtilsServiceFactory( new BrowserPlatformUtilsService( await messagingServiceFactory(cache, opts), opts.platformUtilsServiceOptions.clipboardWriteCallback, - opts.platformUtilsServiceOptions.biometricCallback + opts.platformUtilsServiceOptions.biometricCallback, + opts.platformUtilsServiceOptions.win ) ); } diff --git a/apps/browser/src/background/service_factories/totp-service.factory.ts b/apps/browser/src/background/service_factories/totp-service.factory.ts new file mode 100644 index 00000000000..fe2f5c74905 --- /dev/null +++ b/apps/browser/src/background/service_factories/totp-service.factory.ts @@ -0,0 +1,31 @@ +import { TotpService as AbstractTotpService } from "@bitwarden/common/abstractions/totp.service"; +import { TotpService } from "@bitwarden/common/services/totp.service"; + +import { + cryptoFunctionServiceFactory, + CryptoFunctionServiceInitOptions, +} from "./crypto-function-service.factory"; +import { CachedServices, factory, FactoryOptions } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; + +type TotpServiceOptions = FactoryOptions; + +export type TotpServiceInitOptions = TotpServiceOptions & + CryptoFunctionServiceInitOptions & + LogServiceInitOptions; + +export function totpServiceFacotry( + cache: { totpService?: AbstractTotpService } & CachedServices, + opts: TotpServiceInitOptions +): Promise { + return factory( + cache, + "totpService", + opts, + async () => + new TotpService( + await cryptoFunctionServiceFactory(cache, opts), + await logServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/listeners/onCommandListener.ts b/apps/browser/src/listeners/onCommandListener.ts index 2a33e91e578..294ea51a963 100644 --- a/apps/browser/src/listeners/onCommandListener.ts +++ b/apps/browser/src/listeners/onCommandListener.ts @@ -1,27 +1,15 @@ +import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { GlobalState } from "@bitwarden/common/models/domain/globalState"; -import { AuthService } from "@bitwarden/common/services/auth.service"; -import { CipherService } from "@bitwarden/common/services/cipher.service"; -import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; -import { EncryptService } from "@bitwarden/common/services/encrypt.service"; -import { NoopEventService } from "@bitwarden/common/services/noopEvent.service"; -import { SearchService } from "@bitwarden/common/services/search.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; -import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; -import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service"; +import { authServiceFactory } from "../background/service_factories/auth-service.factory"; +import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory"; +import { CachedServices } from "../background/service_factories/factory-options"; +import { logServiceFactory } from "../background/service_factories/log-service.factory"; +import { BrowserApi } from "../browser/browserApi"; import { AutoFillActiveTabCommand } from "../commands/autoFillActiveTabCommand"; import { Account } from "../models/account"; -import { StateService as AbstractStateService } from "../services/abstractions/state.service"; -import AutofillService from "../services/autofill.service"; -import { BrowserCryptoService } from "../services/browserCrypto.service"; -import BrowserLocalStorageService from "../services/browserLocalStorage.service"; -import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import I18nService from "../services/i18n.service"; -import { KeyGenerationService } from "../services/keyGeneration.service"; -import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service"; -import { StateService } from "../services/state.service"; export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) => { switch (command) { @@ -32,100 +20,44 @@ export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) = }; const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise => { - const logService = new ConsoleLogService(false); - - const cryptoFunctionService = new WebCryptoFunctionService(self); - - const storageService = new BrowserLocalStorageService(); - - const secureStorageService = new BrowserLocalStorageService(); - - const memoryStorageService = new LocalBackedSessionStorageService( - new EncryptService(cryptoFunctionService, logService, false), - new KeyGenerationService(cryptoFunctionService) - ); - - const stateFactory = new StateFactory(GlobalState, Account); - - const stateMigrationService = new StateMigrationService( - storageService, - secureStorageService, - stateFactory - ); - - const stateService: AbstractStateService = new StateService( - storageService, - secureStorageService, - memoryStorageService, // AbstractStorageService - logService, - stateMigrationService, - stateFactory - ); - - await stateService.init(); - - const platformUtils = new BrowserPlatformUtilsService( - null, // MessagingService - null, // clipboardWriteCallback - null // biometricCallback - ); - - const cryptoService = new BrowserCryptoService( - cryptoFunctionService, - null, // AbstractEncryptService - platformUtils, - logService, - stateService - ); - - const settingsService = new SettingsService(stateService); - - const i18nService = new I18nService(chrome.i18n.getUILanguage()); - - await i18nService.init(); - - // Don't love this pt.1 - let searchService: SearchService = null; - - const cipherService = new CipherService( - cryptoService, - settingsService, - null, // ApiService - null, // FileUploadService, - i18nService, - () => searchService, // Don't love this pt.2 - logService, - stateService - ); - - // Don't love this pt.3 - searchService = new SearchService(cipherService, logService, i18nService); - - // TODO: Remove this before we encourage anyone to start using this - const eventService = new NoopEventService(); - - const autofillService = new AutofillService( - cipherService, - stateService, - null, // TotpService - eventService, - logService - ); - - const authService = new AuthService( - cryptoService, // CryptoService - null, // ApiService - null, // TokenService - null, // AppIdService - platformUtils, - null, // MessagingService - logService, - null, // KeyConnectorService - null, // EnvironmentService - stateService, - null, // TwoFactorService - i18nService - ); + const cachedServices: CachedServices = {}; + const opts = { + cryptoFunctionServiceOptions: { + win: self, + }, + encryptServiceOptions: { + logMacFailures: true, + }, + logServiceOptions: { + isDev: false, + }, + platformUtilsServiceOptions: { + clipboardWriteCallback: () => Promise.resolve(), + biometricCallback: () => Promise.resolve(false), + win: self, + }, + stateServiceOptions: { + stateFactory: new StateFactory(GlobalState, Account), + }, + stateMigrationServiceOptions: { + stateFactory: new StateFactory(GlobalState, Account), + }, + apiServiceOptions: { + logoutCallback: () => Promise.resolve(), + }, + keyConnectorServiceOptions: { + logoutCallback: () => Promise.resolve(), + }, + i18nServiceOptions: { + systemLanguage: BrowserApi.getUILanguage(self), + }, + cipherServiceOptions: { + searchServiceFactory: null as () => SearchService, // No dependence on search service + }, + }; + const logService = await logServiceFactory(cachedServices, opts); + const authService = await authServiceFactory(cachedServices, opts); + const autofillService = await autofillServiceFactory(cachedServices, opts); const authStatus = await authService.getAuthStatus(); if (authStatus < AuthenticationStatus.Unlocked) { diff --git a/apps/browser/src/services/autofill.service.ts b/apps/browser/src/services/autofill.service.ts index ff8eca93475..b403a2a679c 100644 --- a/apps/browser/src/services/autofill.service.ts +++ b/apps/browser/src/services/autofill.service.ts @@ -172,14 +172,10 @@ export default class AutofillService implements AutofillServiceInterface { } else { cipher = await this.cipherService.getLastUsedForUrl(tab.url, true); } - - if (cipher == null) { - return null; - } } - if (cipher.reprompt !== CipherRepromptType.None) { - return; + if (cipher == null || cipher.reprompt !== CipherRepromptType.None) { + return null; } const totpCode = await this.doAutoFill({ diff --git a/apps/browser/src/services/browserPlatformUtils.service.spec.ts b/apps/browser/src/services/browserPlatformUtils.service.spec.ts index 21034bcfa47..1f557dc7426 100644 --- a/apps/browser/src/services/browserPlatformUtils.service.spec.ts +++ b/apps/browser/src/services/browserPlatformUtils.service.spec.ts @@ -16,7 +16,7 @@ describe("Browser Utils Service", () => { let browserPlatformUtilsService: BrowserPlatformUtilsService; beforeEach(() => { (window as any).matchMedia = jest.fn().mockReturnValueOnce({}); - browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); + browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null, self); }); afterEach(() => { diff --git a/apps/browser/src/services/browserPlatformUtils.service.ts b/apps/browser/src/services/browserPlatformUtils.service.ts index a9f1c35567d..48c305a91e7 100644 --- a/apps/browser/src/services/browserPlatformUtils.service.ts +++ b/apps/browser/src/services/browserPlatformUtils.service.ts @@ -19,7 +19,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService constructor( private messagingService: MessagingService, private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, - private biometricCallback: () => Promise + private biometricCallback: () => Promise, + private win: Window & typeof globalThis ) {} getDevice(): DeviceType { @@ -33,8 +34,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService ) { this.deviceCache = DeviceType.FirefoxExtension; } else if ( - (self.opr && self.opr.addons) || - self.opera || + (!!this.win.opr && !!opr.addons) || + !!this.win.opera || navigator.userAgent.indexOf(" OPR/") >= 0 ) { this.deviceCache = DeviceType.OperaExtension; @@ -42,7 +43,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService this.deviceCache = DeviceType.EdgeExtension; } else if (navigator.userAgent.indexOf(" Vivaldi/") !== -1) { this.deviceCache = DeviceType.VivaldiExtension; - } else if (window.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1) { + } else if (this.win.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1) { this.deviceCache = DeviceType.ChromeExtension; } else if (navigator.userAgent.indexOf(" Safari/") !== -1) { this.deviceCache = DeviceType.SafariExtension; @@ -178,8 +179,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } copyToClipboard(text: string, options?: any): void { - let win = window; - let doc = window.document; + let win = this.win; + let doc = this.win.document; if (options && (options.window || options.win)) { win = options.window || options.win; doc = win.document; @@ -238,8 +239,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } async readFromClipboard(options?: any): Promise { - let win = window; - let doc = window.document; + let win = this.win; + let doc = this.win.document; if (options && (options.window || options.win)) { win = options.window || options.win; doc = win.document; @@ -335,7 +336,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } sidebarViewName(): string { - if (window.chrome.sidebarAction && this.isFirefox()) { + if (this.win.chrome.sidebarAction && this.isFirefox()) { return "sidebar"; } else if (this.isOpera() && typeof opr !== "undefined" && opr.sidebarAction) { return "sidebar_panel"; diff --git a/libs/common/src/services/noopEvent.service.ts b/libs/common/src/services/noopEvent.service.ts deleted file mode 100644 index 9a49d5a8061..00000000000 --- a/libs/common/src/services/noopEvent.service.ts +++ /dev/null @@ -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(); - } -} From ae5110aaeea99e8714be1236df00593e34974b28 Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Tue, 11 Oct 2022 19:45:59 +0200 Subject: [PATCH 20/22] Fix linting issue caused by #3259 (#3743) --- libs/common/spec/services/policy.service.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/common/spec/services/policy.service.spec.ts b/libs/common/spec/services/policy.service.spec.ts index 8329d38fe26..0423448180a 100644 --- a/libs/common/spec/services/policy.service.spec.ts +++ b/libs/common/spec/services/policy.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { BehaviorSubject, firstValueFrom } from "rxjs"; From fd5bd3744dd480569c2924a6ba80487237f5df69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Oct 2022 13:35:16 -0600 Subject: [PATCH 21/22] Bump Web version to 2022.10.0 (#3747) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/web/package.json | 2 +- package-lock.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index cbf65094dd7..4ddd9cb05b5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2022.9.2", + "version": "2022.10.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/package-lock.json b/package-lock.json index 129ac3a1195..66c638abce4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -234,7 +234,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2022.9.2" + "version": "2022.10.0" }, "libs/angular": { "name": "@bitwarden/angular", @@ -47239,7 +47239,7 @@ "postcss": "^7.0.36", "postcss-loader": "^4.2.0", "raw-loader": "^4.0.2", - "react": "^16.14.0", + "react": "^18.0.0", "react-dom": "^16.14.0", "read-pkg-up": "^7.0.1", "regenerator-runtime": "^0.13.7", From e290492d14f858197647c22226a26d591cebb173 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 11 Oct 2022 15:39:20 -0400 Subject: [PATCH 22/22] [PS-1569] Fix spelling of totpServiceFactory (#3746) --- .../background/service_factories/autofill-service.factory.ts | 4 ++-- .../src/background/service_factories/totp-service.factory.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/background/service_factories/autofill-service.factory.ts b/apps/browser/src/background/service_factories/autofill-service.factory.ts index 1d67a2735be..a14cd1dd8c3 100644 --- a/apps/browser/src/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/background/service_factories/autofill-service.factory.ts @@ -6,7 +6,7 @@ import { EventServiceInitOptions, eventServiceFactory } from "./event-service.fa import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; -import { totpServiceFacotry, TotpServiceInitOptions } from "./totp-service.factory"; +import { totpServiceFactory, TotpServiceInitOptions } from "./totp-service.factory"; type AutoFillServiceOptions = FactoryOptions; @@ -29,7 +29,7 @@ export function autofillServiceFactory( new AutofillService( await cipherServiceFactory(cache, opts), await stateServiceFactory(cache, opts), - await totpServiceFacotry(cache, opts), + await totpServiceFactory(cache, opts), await eventServiceFactory(cache, opts), await logServiceFactory(cache, opts) ) diff --git a/apps/browser/src/background/service_factories/totp-service.factory.ts b/apps/browser/src/background/service_factories/totp-service.factory.ts index fe2f5c74905..07556489de5 100644 --- a/apps/browser/src/background/service_factories/totp-service.factory.ts +++ b/apps/browser/src/background/service_factories/totp-service.factory.ts @@ -14,7 +14,7 @@ export type TotpServiceInitOptions = TotpServiceOptions & CryptoFunctionServiceInitOptions & LogServiceInitOptions; -export function totpServiceFacotry( +export function totpServiceFactory( cache: { totpService?: AbstractTotpService } & CachedServices, opts: TotpServiceInitOptions ): Promise {