diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index ecd1e404944..42d012d5a98 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -41,6 +41,8 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: repo_url: ${{ steps.gen_vars.outputs.repo_url }} adj_build_number: ${{ steps.gen_vars.outputs.adj_build_number }} @@ -236,7 +238,6 @@ jobs: needs: - setup - locales-test - - check-run env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -350,7 +351,7 @@ jobs: crowdin-push: name: Crowdin Push - if: github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 needs: - build @@ -399,7 +400,7 @@ jobs: - name: Check if any job failed if: | github.event_name != 'pull_request_target' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-browser') && contains(needs.*.result, 'failure') run: exit 1 diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 98ba5b9fd8a..ac39ab2608b 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -42,6 +42,8 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: package_version: ${{ steps.retrieve-package-version.outputs.package_version }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} @@ -398,13 +400,12 @@ jobs: - cli - cli-windows - snap - - check-run steps: - name: Check if any job failed working-directory: ${{ github.workspace }} if: | github.event_name != 'pull_request_target' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-cli') && contains(needs.*.result, 'failure') run: exit 1 diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 83389c5bbec..221c998247f 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -40,6 +40,8 @@ jobs: electron-verify: name: Verify Electron Version runs-on: ubuntu-22.04 + needs: + - check-run steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -61,6 +63,8 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: package_version: ${{ steps.retrieve-version.outputs.package_version }} release_channel: ${{ steps.release-channel.outputs.channel }} @@ -251,7 +255,6 @@ jobs: runs-on: windows-2022 needs: - setup - - check-run defaults: run: shell: pwsh @@ -464,7 +467,6 @@ jobs: runs-on: macos-13 needs: - setup - - check-run env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -1056,9 +1058,8 @@ jobs: - name: Deploy to TestFlight id: testflight-deploy if: | - (github.ref == 'refs/heads/main' - || github.ref == 'refs/heads/rc' - || github.ref == 'refs/heads/hotfix-rc-desktop') + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') env: APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP @@ -1073,9 +1074,8 @@ jobs: - name: Post message to a Slack channel id: slack-message if: | - (github.ref == 'refs/heads/main' - || github.ref == 'refs/heads/rc' - || github.ref == 'refs/heads/hotfix-rc-desktop') + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0 with: channel-id: C074F5UESQ0 @@ -1352,7 +1352,7 @@ jobs: - name: Check if any job failed if: | github.event_name != 'pull_request_target' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') && contains(needs.*.result, 'failure') run: exit 1 diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 4ce5bad790f..ba4f2599f37 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -44,6 +44,8 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + needs: + - check-run outputs: version: ${{ steps.version.outputs.value }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} @@ -67,7 +69,8 @@ jobs: build-artifacts: name: Build artifacts runs-on: ubuntu-22.04 - needs: setup + needs: + - setup env: _VERSION: ${{ needs.setup.outputs.version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -151,7 +154,6 @@ jobs: needs: - setup - build-artifacts - - check-run strategy: fail-fast: false matrix: @@ -261,10 +263,9 @@ jobs: crowdin-push: name: Crowdin Push - if: github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' needs: - build-artifacts - - check-run runs-on: ubuntu-22.04 steps: - name: Check out repo @@ -302,7 +303,6 @@ jobs: runs-on: ubuntu-22.04 needs: - build-artifacts - - check-run steps: - name: Login to Azure - CI Subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -346,7 +346,7 @@ jobs: - name: Check if any job failed if: | github.event_name != 'pull_request_target' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-web') && contains(needs.*.result, 'failure') run: exit 1 diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 696bdb8b896..e79f6f69a36 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -436,9 +436,7 @@ export default class AutofillService implements AutofillServiceInterface { didAutofill = true; if (!options.skipLastUsed) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.cipherService.updateLastUsedDate(options.cipher.id); + await this.cipherService.updateLastUsedDate(options.cipher.id); } // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/cli/package.json b/apps/cli/package.json index 622c1273823..8ddb5daccd2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -80,7 +80,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.58", + "tldts": "6.1.60", "zxcvbn": "4.4.2" } } diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 7e1d7193b58..f57f067907a 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -14,7 +14,7 @@ "module-alias": "2.2.3", "node-ipc": "9.2.1", "ts-node": "10.9.2", - "uuid": "11.0.1", + "uuid": "11.0.3", "yargs": "17.7.2" }, "devDependencies": { @@ -421,9 +421,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.1.tgz", - "integrity": "sha512-wt9UB5EcLhnboy1UvA1mvGPXkIIrHSu+3FmUksARfdVw9tuPf3CH/CohxO0Su1ApoKAeT6BVzAJIvjTuQVSmuQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 0c38902ea4c..ed2c4bb29cf 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -19,7 +19,7 @@ "module-alias": "2.2.3", "node-ipc": "9.2.1", "ts-node": "10.9.2", - "uuid": "11.0.1", + "uuid": "11.0.3", "yargs": "17.7.2" }, "devDependencies": { diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts index 2c8b579b994..bc354009775 100644 --- a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts @@ -46,7 +46,7 @@ export class SecretsManagerTrialFreeStepperComponent implements OnInit { protected formBuilder: UntypedFormBuilder, protected i18nService: I18nService, protected organizationBillingService: OrganizationBillingService, - private router: Router, + protected router: Router, ) {} ngOnInit(): void { diff --git a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html index 1acf4c32097..aeec49e5276 100644 --- a/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html +++ b/apps/web/src/app/auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html @@ -22,12 +22,29 @@ bitButton buttonType="primary" [disabled]="formGroup.get('name').invalid" + [loading]="createOrganizationLoading" + (click)="createOrganizationOnTrial()" + *ngIf="enableTrialPayment$ | async" + > + {{ "startTrial" | i18n }} + + {{ "next" | i18n }} - + (); + protected enableTrialPayment$ = this.configService.getFeatureFlag$( + FeatureFlag.TrialPaymentOptional, + ); + + constructor( + private route: ActivatedRoute, + private configService: ConfigService, + protected formBuilder: UntypedFormBuilder, + protected i18nService: I18nService, + protected organizationBillingService: OrganizationBillingService, + protected router: Router, + ) { + super(formBuilder, i18nService, organizationBillingService, router); + } + + async ngOnInit(): Promise { + this.referenceEventRequest = new ReferenceEventRequest(); + this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website"; + + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => { + if (trialFlowOrgs.includes(qParams.org)) { + if (qParams.org === ValidOrgParams.teamsStarter) { + this.plan = PlanType.TeamsStarter; + } else if (qParams.org === ValidOrgParams.teams) { + this.plan = PlanType.TeamsAnnually; + } else if (qParams.org === ValidOrgParams.enterprise) { + this.plan = PlanType.EnterpriseAnnually; + } + } + }); + } + organizationCreated(event: OrganizationCreatedEvent) { this.organizationId = event.organizationId; this.billingSubLabel = event.planDescription; @@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial this.verticalStepper.previous(); } + async createOrganizationOnTrial(): Promise { + this.createOrganizationLoading = true; + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ + organization: { + name: this.formGroup.get("name").value, + billingEmail: this.formGroup.get("email").value, + initiationPath: "Secrets Manager trial from marketing website", + }, + plan: { + type: this.plan, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + passwordManagerSeats: 1, + secretsManagerSeats: 1, + }, + }); + + this.organizationId = response?.id; + this.subLabels.organizationInfo = response?.name; + this.createOrganizationLoading = false; + this.verticalStepper.next(); + } + get createAccountLabel() { const organizationType = this.productType === ProductTierType.TeamsStarter diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html index ed1dc6cda9b..077836a7634 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html @@ -91,12 +91,17 @@ bitButton buttonType="primary" [disabled]="orgInfoFormGroup.get('name').invalid" - cdkStepperNext + [loading]="loading" + (click)="createOrganizationOnTrial()" > - {{ "next" | i18n }} + {{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }} - + { let policyServiceMock: MockProxy; let routerServiceMock: MockProxy; let acceptOrgInviteServiceMock: MockProxy; + let organizationBillingServiceMock: MockProxy; + let configServiceMock: MockProxy; beforeEach(() => { // only define services directly that we want to mock return values in this component @@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => { policyServiceMock = mock(); routerServiceMock = mock(); acceptOrgInviteServiceMock = mock(); + organizationBillingServiceMock = mock(); + configServiceMock = mock(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => { provide: AcceptOrganizationInviteService, useValue: acceptOrgInviteServiceMock, }, + { + provide: OrganizationBillingService, + useValue: organizationBillingServiceMock, + }, + { + provide: ConfigService, + useValue: configServiceMock, + }, ], schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component) }).compileComponents(); diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts index f8718b0a420..7892283a387 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts @@ -9,8 +9,15 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { + OrganizationInformation, + PlanInformation, + OrganizationBillingServiceAbstraction as OrganizationBillingService, +} from "@bitwarden/common/billing/abstractions/organization-billing.service"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -25,7 +32,7 @@ import { OrganizationInvite } from "../organization-invite/organization-invite"; import { RouterService } from "./../../core/router.service"; import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; -enum ValidOrgParams { +export enum ValidOrgParams { families = "families", enterprise = "enterprise", teams = "teams", @@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { productTier: ProductTierType; accountCreateOnly = true; useTrialStepper = false; + loading = false; policies: Policy[]; enforcedPolicyOptions: MasterPasswordPolicyOptions; trialFlowOrgs: string[] = [ @@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { } private destroy$ = new Subject(); + protected enableTrialPayment$ = this.configService.getFeatureFlag$( + FeatureFlag.TrialPaymentOptional, + ); constructor( private route: ActivatedRoute, @@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { private i18nService: I18nService, private routerService: RouterService, private acceptOrgInviteService: AcceptOrganizationInviteService, + private organizationBillingService: OrganizationBillingService, + private configService: ConfigService, ) {} async ngOnInit(): Promise { @@ -215,6 +228,30 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { } } + async createOrganizationOnTrial() { + this.loading = true; + const organization: OrganizationInformation = { + name: this.orgInfoFormGroup.get("name").value, + billingEmail: this.orgInfoFormGroup.get("email").value, + initiationPath: "Password Manager trial from marketing website", + }; + + const plan: PlanInformation = { + type: this.plan, + passwordManagerSeats: 1, + }; + + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ + organization, + plan, + }); + + this.orgId = response?.id; + this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`; + this.loading = false; + this.verticalStepper.next(); + } + createdAccount(email: string) { this.email = email; this.orgInfoFormGroup.get("email")?.setValue(email); diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index e6ed6475c4a..878672a1fb9 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -345,16 +345,22 @@
diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index 06acf2142a5..98e6efcd8bd 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -1,10 +1,13 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Location } from "@angular/common"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, FormControl, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { lastValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; @@ -13,8 +16,12 @@ import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank. import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService, ToastService } from "@bitwarden/components"; +import { FreeTrial } from "../../core/types/free-trial"; +import { TrialFlowService } from "../services/trial-flow.service"; + import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; import { AdjustPaymentDialogResult, @@ -26,7 +33,7 @@ import { TaxInfoComponent } from "./tax-info.component"; templateUrl: "payment-method.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class PaymentMethodComponent implements OnInit { +export class PaymentMethodComponent implements OnInit, OnDestroy { @ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent; loading = false; @@ -37,6 +44,7 @@ export class PaymentMethodComponent implements OnInit { paymentMethodType = PaymentMethodType; organizationId: string; isUnpaid = false; + organization: Organization; verifyBankForm = this.formBuilder.group({ amount1: new FormControl(null, [ @@ -52,6 +60,8 @@ export class PaymentMethodComponent implements OnInit { }); taxForm = this.formBuilder.group({}); + launchPaymentModalAutomatically = false; + protected freeTrialData: FreeTrial; constructor( protected apiService: ApiService, @@ -59,12 +69,30 @@ export class PaymentMethodComponent implements OnInit { protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, private router: Router, + private location: Location, private logService: LogService, private route: ActivatedRoute, private formBuilder: FormBuilder, private dialogService: DialogService, private toastService: ToastService, - ) {} + private trialFlowService: TrialFlowService, + private organizationService: OrganizationService, + protected syncService: SyncService, + ) { + const state = this.router.getCurrentNavigation()?.extras?.state; + // incase the above state is undefined or null we use redundantState + const redundantState: any = location.getState(); + if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { + this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; + } else if ( + redundantState && + Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") + ) { + this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; + } else { + this.launchPaymentModalAutomatically = false; + } + } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -88,27 +116,37 @@ export class PaymentMethodComponent implements OnInit { return; } this.loading = true; - if (this.forOrganization) { const billingPromise = this.organizationApiService.getBilling(this.organizationId); const organizationSubscriptionPromise = this.organizationApiService.getSubscription( this.organizationId, ); + const organizationPromise = this.organizationService.get(this.organizationId); - [this.billing, this.org] = await Promise.all([ + [this.billing, this.org, this.organization] = await Promise.all([ billingPromise, organizationSubscriptionPromise, + organizationPromise, ]); + this.determineOrgsWithUpcomingPaymentIssues(); } else { const billingPromise = this.apiService.getUserBillingPayment(); const subPromise = this.apiService.getUserSubscription(); [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); } - this.isUnpaid = this.subscription?.status === "unpaid" ?? false; - this.loading = false; + // If the flag `launchPaymentModalAutomatically` is set to true, + // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. + // This delay ensures that any prior UI/rendering operations complete before triggering the modal. + if (this.launchPaymentModalAutomatically) { + window.setTimeout(async () => { + await this.changePayment(); + this.launchPaymentModalAutomatically = false; + this.location.replaceState(this.location.path(), "", {}); + }, 800); + } }; addCredit = async () => { @@ -132,6 +170,11 @@ export class PaymentMethodComponent implements OnInit { }); const result = await lastValueFrom(dialogRef.closed); if (result === AdjustPaymentDialogResult.Adjusted) { + this.location.replaceState(this.location.path(), "", {}); + if (this.launchPaymentModalAutomatically && !this.organization.enabled) { + await this.syncService.fullSync(true); + } + this.launchPaymentModalAutomatically = false; await this.load(); } }; @@ -162,6 +205,14 @@ export class PaymentMethodComponent implements OnInit { }); }; + determineOrgsWithUpcomingPaymentIssues() { + this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + this.organization, + this.org, + this.billing?.paymentSource, + ); + } + get isCreditBalance() { return this.billing == null || this.billing.balance <= 0; } @@ -203,4 +254,8 @@ export class PaymentMethodComponent implements OnInit { get subscription() { return this.sub?.subscription ?? this.org?.subscription ?? null; } + + ngOnDestroy(): void { + this.launchPaymentModalAutomatically = false; + } } diff --git a/apps/web/src/app/core/types/free-trial.ts b/apps/web/src/app/core/types/free-trial.ts new file mode 100644 index 00000000000..ee5fb921621 --- /dev/null +++ b/apps/web/src/app/core/types/free-trial.ts @@ -0,0 +1,7 @@ +export type FreeTrial = { + remainingDays: number; + message: string; + shownBanner: boolean; + organizationId: string; + organizationName: string; +}; diff --git a/apps/web/src/app/layouts/org-switcher/org-switcher.component.html b/apps/web/src/app/layouts/org-switcher/org-switcher.component.html index 1dd03d03230..f34d32f5983 100644 --- a/apps/web/src/app/layouts/org-switcher/org-switcher.component.html +++ b/apps/web/src/app/layouts/org-switcher/org-switcher.component.html @@ -22,6 +22,7 @@ [route]="['../', org.id]" (mainContentClicked)="toggle()" [routerLinkActiveOptions]="{ exact: true }" + (click)="handleUnpaidSubscription(org)" > { - const asAdmin = this.organization?.canEditAllCiphers; + const cipherIsUnassigned = + !this.cipher.collectionIds || this.cipher.collectionIds?.length === 0; + + // Delete the cipher as an admin when: + // - the organization allows for owners/admins to manage all collections/items + // - the cipher is unassigned + const asAdmin = this.organization?.canEditAllCiphers || cipherIsUnassigned; + if (this.cipher.isDeleted) { await this.cipherService.deleteWithServer(this.cipher.id, asAdmin); } else { diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html index 63b099faf3d..f3ecead886b 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html @@ -1,3 +1,23 @@ + + {{ freeTrialMessage(organization) }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + ; VisibleVaultBanner = VisibleVaultBanner; + @Input() organizationsPaymentStatus: FreeTrial[] = []; - constructor(private vaultBannerService: VaultBannersService) { + constructor( + private vaultBannerService: VaultBannersService, + private router: Router, + private i18nService: I18nService, + ) { this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$; } @@ -34,6 +42,17 @@ export class VaultBannersComponent implements OnInit { await this.determineVisibleBanners(); } + async navigateToPaymentMethod(organizationId: string): Promise { + const navigationExtras = { + state: { launchPaymentModalAutomatically: true }, + }; + + await this.router.navigate( + ["organizations", organizationId, "billing", "payment-method"], + navigationExtras, + ); + } + /** Determine which banners should be present */ private async determineVisibleBanners(): Promise { const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(); @@ -46,4 +65,22 @@ export class VaultBannersComponent implements OnInit { showLowKdf ? VisibleVaultBanner.KDFSettings : null, ].filter(Boolean); // remove all falsy values, i.e. null } + + freeTrialMessage(organization: FreeTrial) { + if (organization.remainingDays >= 2) { + return this.i18nService.t( + "freeTrialEndPromptAboveTwoDays", + organization.organizationName, + organization.remainingDays.toString(), + ); + } else if (organization.remainingDays === 1) { + return this.i18nService.t("freeTrialEndPromptForOneDay", organization.organizationName); + } else { + return this.i18nService.t("freeTrialEndPromptForLessThanADay", organization.organizationName); + } + } + + trackBy(index: number) { + return index; + } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 92b9034fa35..09a7356c452 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -1,12 +1,16 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Router } from "@angular/router"; import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { @@ -40,7 +44,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { isLoaded = false; protected destroy$: Subject = new Subject(); - + private router = inject(Router); get filtersList() { return this.filters ? Object.values(this.filters) : []; } @@ -85,6 +89,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) {} async ngOnInit(): Promise { @@ -111,6 +117,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy { null, this.i18nService.t("disabledOrganizationFilterError"), ); + const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); + if (metadata.isSubscriptionUnpaid) { + const confirmed = await this.promptForPaymentNavigation(orgNode.node); + if (confirmed) { + await this.navigateToPaymentMethod(orgNode.node.id); + } + } return; } const filter = this.activeFilter; @@ -123,6 +136,32 @@ export class VaultFilterComponent implements OnInit, OnDestroy { await this.vaultFilterService.expandOrgFilter(); }; + private async promptForPaymentNavigation(org: Organization): Promise { + if (!org?.isOwner) { + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("close"), + cancelButtonText: null, + }); + return false; + } + return await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("suspendedOrganizationTitle", org?.name), + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: this.i18nService.t("continue"), + cancelButtonText: this.i18nService.t("close"), + }); + } + + private async navigateToPaymentMethod(orgId: string) { + await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], { + state: { launchPaymentModalAutomatically: true }, + }); + } + applyTypeFilter = async (filterNode: TreeNode): Promise => { const filter = this.activeFilter; filter.resetFilter(); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index b2c4fda57d0..679d2ce6f7e 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -1,4 +1,4 @@ - + ; private activeUserId: UserId; + protected organizationsPaymentStatus: FreeTrial[] = []; private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); private destroy$ = new Subject(); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private syncService: SyncService, @@ -211,6 +232,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private accountService: AccountService, private cipherFormConfigService: DefaultCipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + protected billingApiService: BillingApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} async ngOnInit() { @@ -309,7 +333,6 @@ export class VaultComponent implements OnInit, OnDestroy { if (filter.collectionId === undefined || filter.collectionId === Unassigned) { return []; } - let collectionsToReturn = []; if (filter.organizationId !== undefined && filter.collectionId === All) { collectionsToReturn = collections @@ -362,7 +385,6 @@ export class VaultComponent implements OnInit, OnDestroy { filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled), switchMap(async (params) => { const cipherId = getCipherIdFromParams(params); - if (cipherId) { if (await this.cipherService.get(cipherId)) { let action = params.action; @@ -393,6 +415,32 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + const organizationsPaymentStatus$ = this.organizationService.organizations$.pipe( + switchMap((allOrganizations) => { + return combineLatest( + allOrganizations + .filter((org) => org.isOwner) + .map((org) => + combineLatest([ + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]).pipe( + map(([subscription, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + subscription, + billing?.paymentSource, + ); + }), + ), + ), + ); + }), + map((results) => results.filter((result) => result.shownBanner)), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -406,6 +454,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers$, collections$, selectedCollection$, + organizationsPaymentStatus$, ]), ), takeUntil(this.destroy$), @@ -419,6 +468,7 @@ export class VaultComponent implements OnInit, OnDestroy { ciphers, collections, selectedCollection, + organizationsPaymentStatus, ]) => { this.filter = filter; this.canAccessPremium = canAccessPremium; @@ -434,7 +484,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.showBulkMove = filter.type !== "trash"; this.isEmpty = collections?.length === 0 && ciphers?.length === 0; - + this.organizationsPaymentStatus = organizationsPaymentStatus; this.performingInitialLoad = false; this.refreshing = false; }, diff --git a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts index 8a3f25ab2c7..211d2346230 100644 --- a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.component.ts @@ -3,9 +3,11 @@ import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { DialogService } from "@bitwarden/components"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -38,8 +40,17 @@ export class VaultFilterComponent protected policyService: PolicyService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected billingApiService: BillingApiServiceAbstraction, + protected dialogService: DialogService, ) { - super(vaultFilterService, policyService, i18nService, platformUtilsService); + super( + vaultFilterService, + policyService, + i18nService, + platformUtilsService, + billingApiService, + dialogService, + ); } async ngOnInit() { diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 0bcdc52eaeb..9e9264e77cd 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -1,3 +1,25 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + + ; + protected freeTrial$: Observable; /** * A list of collections that the user can assign items to and edit those items within. * @protected @@ -183,6 +197,21 @@ export class VaultComponent implements OnInit, OnDestroy { protected addAccessStatus$ = new BehaviorSubject(0); private extensionRefreshEnabled: boolean; private vaultItemDialogRef?: DialogRef | undefined; + private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe( + filter((organizations) => organizations.length === 1), + switchMap(([organization]) => + from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe( + switchMap((organizationMetaData) => + from( + this.trialFlowService.handleUnpaidSubscriptionDialog( + organization, + organizationMetaData, + ), + ), + ), + ), + ), + ); constructor( private route: ActivatedRoute, @@ -214,6 +243,9 @@ export class VaultComponent implements OnInit, OnDestroy { private toastService: ToastService, private configService: ConfigService, private cipherFormConfigService: CipherFormConfigService, + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, + protected billingApiService: BillingApiServiceAbstraction, ) {} async ngOnInit() { @@ -546,6 +578,26 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe(); + + this.freeTrial$ = organization$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + ); + firstSetup$ .pipe( switchMap(() => this.refresh$), @@ -596,6 +648,13 @@ export class VaultComponent implements OnInit, OnDestroy { ); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organization?.id}`, "billing", "payment-method"], + { state: { launchPaymentModalAutomatically: true } }, + ); + } + addAccessToggle(e: AddAccessStatusType) { this.addAccessStatus$.next(e); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index f4baf8273dc..00d2102c786 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -560,7 +560,7 @@ "message": "Secure note" }, "typeSshKey": { - "message": "Ssh key" + "message": "SSH key" }, "typeLoginPlural": { "message": "Logins" @@ -3837,6 +3837,55 @@ "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, + "freeTrialEndPrompt": { + "message": "Your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$1", + "example": "You must set up 2FA on your user account before you can join this organization." + } + } + }, + "freeTrialEndPromptAboveTwoDays": { + "message": "$ORGANIZATION$, your free trial ends in $COUNT$ days. To maintain your subscription,", + "placeholders": { + "count": { + "content": "$2", + "example": "organization name" + }, + "organization": { + "content": "$1", + "example": "remaining days" + } + } + }, + "freeTrialEndPromptForOneDay": { + "message": "$ORGANIZATION$, your free trial ends tomorrow. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndPromptForOneDayNoOrgName": { + "message": "Your free trial ends tomorrow. To maintain your subscription," + }, + "freeTrialEndPromptForLessThanADay": { + "message": "$ORGANIZATION$, your free trial ends today. To maintain your subscription,", + "placeholders": { + "organization": { + "content": "$1", + "example": "organization name" + } + } + }, + "freeTrialEndingSoonWithoutOrgName": { + "message": "Your free trial ends today. To maintain your subscription," + }, + "routeToPaymentMethodTrigger": { + "message": "add a payment method." + }, "joinOrganization": { "message": "Join organization" }, @@ -8444,7 +8493,7 @@ }, "addAPaymentMethod": { "message": "add a payment method", - "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'" + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method'" }, "organizationInformation": { "message": "Organization information" @@ -9631,5 +9680,20 @@ "example": "First 8 Character of a GUID" } } + }, + "suspendedOrganizationTitle": { + "message": "The $ORGANIZATION$ is suspended", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme c" + } + } + }, + "suspendedUserOrgMessage": { + "message": "Contact your organization owner for assistance." + }, + "suspendedOwnerOrgMessage": { + "message": "To regain access to your organization, add a payment method." } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index a82e35afb60..31746e7601c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -1,3 +1,24 @@ + + + {{ freeTrial.message }} + + {{ "routeToPaymentMethodTrigger" | i18n }} + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 7073b4c289f..bf2dbb76ad3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { map, Observable, @@ -12,14 +12,20 @@ import { take, share, firstValueFrom, - concatMap, + of, + filter, } from "rxjs"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; +import { TrialFlowService } from "@bitwarden/web-vault/app/billing/services/trial-flow.service"; +import { FreeTrial } from "@bitwarden/web-vault/app/core/types/free-trial"; import { OrganizationCounts } from "../models/view/counts.view"; import { ProjectListView } from "../models/view/project-list.view"; @@ -81,6 +87,8 @@ export class OverviewComponent implements OnInit, OnDestroy { protected showOnboarding = false; protected loading = true; protected organizationEnabled = false; + protected organization: Organization; + protected i18n: I18nPipe; protected onboardingTasks$: Observable; protected view$: Observable<{ @@ -91,6 +99,7 @@ export class OverviewComponent implements OnInit, OnDestroy { tasks: OrganizationTasks; counts: OrganizationCounts; }>; + protected freeTrial$: Observable; constructor( private route: ActivatedRoute, @@ -104,6 +113,10 @@ export class OverviewComponent implements OnInit, OnDestroy { private i18nService: I18nService, private smOnboardingTasksService: SMOnboardingTasksService, private logService: LogService, + private router: Router, + + private organizationApiService: OrganizationApiServiceAbstraction, + private trialFlowService: TrialFlowService, ) {} ngOnInit() { @@ -114,18 +127,35 @@ export class OverviewComponent implements OnInit, OnDestroy { distinctUntilChanged(), ); - orgId$ - .pipe( - concatMap(async (orgId) => await this.organizationService.get(orgId)), - takeUntil(this.destroy$), - ) - .subscribe((org) => { - this.organizationId = org.id; - this.organizationName = org.name; - this.userIsAdmin = org.isAdmin; - this.loading = true; - this.organizationEnabled = org.enabled; - }); + const org$ = orgId$.pipe(switchMap((orgId) => this.organizationService.get(orgId))); + + org$.pipe(takeUntil(this.destroy$)).subscribe((org) => { + this.organizationId = org.id; + this.organization = org; + this.organizationName = org.name; + this.userIsAdmin = org.isAdmin; + this.loading = true; + this.organizationEnabled = org.enabled; + }); + + this.freeTrial$ = org$.pipe( + filter((org) => org.isOwner), + switchMap((org) => + combineLatest([ + of(org), + this.organizationApiService.getSubscription(org.id), + this.organizationApiService.getBilling(org.id), + ]), + ), + map(([org, sub, billing]) => { + return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues( + org, + sub, + billing?.paymentSource, + ); + }), + takeUntil(this.destroy$), + ); const projects$ = combineLatest([ orgId$, @@ -197,6 +227,15 @@ export class OverviewComponent implements OnInit, OnDestroy { }); } + async navigateToPaymentMethod() { + await this.router.navigate( + ["organizations", `${this.organizationId}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts index 72039f532ae..b9c09a0d671 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts @@ -1,5 +1,7 @@ import { NgModule } from "@angular/core"; +import { BannerModule } from "@bitwarden/components"; + import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; @@ -8,7 +10,7 @@ import { OverviewComponent } from "./overview.component"; import { SectionComponent } from "./section.component"; @NgModule({ - imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule], + imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule, BannerModule], declarations: [OverviewComponent, SectionComponent], providers: [], }) diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index e66fc0cf12a..051275f7945 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction { getLicense: (id: string, installationId: string) => Promise; getAutoEnrollStatus: (identifier: string) => Promise; create: (request: OrganizationCreateRequest) => Promise; + createWithoutPayment: ( + request: OrganizationNoPaymentMethodCreateRequest, + ) => Promise; createLicense: (data: FormData) => Promise; save: (id: string, request: OrganizationUpdateRequest) => Promise; updatePayment: (id: string, request: PaymentRequest) => Promise; diff --git a/libs/common/src/admin-console/models/request/organization-create.request.ts b/libs/common/src/admin-console/models/request/organization-create.request.ts index 9f0441c4340..98f19bebaf4 100644 --- a/libs/common/src/admin-console/models/request/organization-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization-create.request.ts @@ -1,32 +1,7 @@ -import { PaymentMethodType, PlanType } from "../../../billing/enums"; -import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PaymentMethodType } from "../../../billing/enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; -import { OrganizationKeysRequest } from "./organization-keys.request"; - -export class OrganizationCreateRequest { - name: string; - businessName: string; - billingEmail: string; - planType: PlanType; - key: string; - keys: OrganizationKeysRequest; +export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest { paymentMethodType: PaymentMethodType; paymentToken: string; - additionalSeats: number; - maxAutoscaleSeats: number; - additionalStorageGb: number; - premiumAccessAddon: boolean; - collectionName: string; - taxIdNumber: string; - billingAddressLine1: string; - billingAddressLine2: string; - billingAddressCity: string; - billingAddressState: string; - billingAddressPostalCode: string; - billingAddressCountry: string; - useSecretsManager: boolean; - additionalSmSeats: number; - additionalServiceAccounts: number; - isFromSecretsManagerTrial: boolean; - initiationPath: InitiationPath; } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 2ff4f2321a3..a2259d73cc5 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; +import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; @@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new OrganizationResponse(r); } + async createWithoutPayment( + request: OrganizationNoPaymentMethodCreateRequest, + ): Promise { + const r = await this.apiService.send( + "POST", + "/organizations/create-without-payment", + request, + true, + true, + ); + // Forcing a sync will notify organization service that they need to repull + await this.syncService.fullSync(true); + return new OrganizationResponse(r); + } + async createLicense(data: FormData): Promise { const r = await this.apiService.send( "POST", diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index d19724b600a..72902baa30e 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction { purchaseSubscription: (subscription: SubscriptionInformation) => Promise; startFree: (subscription: SubscriptionInformation) => Promise; + + purchaseSubscriptionNoPaymentMethod: ( + subscription: SubscriptionInformation, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts new file mode 100644 index 00000000000..b48caec8dfc --- /dev/null +++ b/libs/common/src/billing/models/request/organization-no-payment-method-create-request.ts @@ -0,0 +1,29 @@ +import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request"; +import { InitiationPath } from "../../../models/request/reference-event.request"; +import { PlanType } from "../../enums"; + +export class OrganizationNoPaymentMethodCreateRequest { + name: string; + businessName: string; + billingEmail: string; + planType: PlanType; + key: string; + keys: OrganizationKeysRequest; + additionalSeats: number; + maxAutoscaleSeats: number; + additionalStorageGb: number; + premiumAccessAddon: boolean; + collectionName: string; + taxIdNumber: string; + billingAddressLine1: string; + billingAddressLine2: string; + billingAddressCity: string; + billingAddressState: string; + billingAddressPostalCode: string; + billingAddressCountry: string; + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; + isFromSecretsManagerTrial: boolean; + initiationPath: InitiationPath; +} diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index 3d846e6c987..ae6d1ac92c1 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { isEligibleForSelfHost: boolean; isManaged: boolean; isOnSecretsManagerStandalone: boolean; + isSubscriptionUnpaid: boolean; constructor(response: any) { super(response); this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isManaged = this.getResponseProperty("IsManaged"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); + this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid"); } } diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index eebea0ca74e..efc36278532 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -17,6 +17,7 @@ import { SubscriptionInformation, } from "../abstractions/organization-billing.service"; import { PlanType } from "../enums"; +import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; interface OrganizationKeys { encryptedKey: EncString; @@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs return response; } + async purchaseSubscriptionNoPaymentMethod( + subscription: SubscriptionInformation, + ): Promise { + const request = new OrganizationNoPaymentMethodCreateRequest(); + + const organizationKeys = await this.makeOrganizationKeys(); + + this.setOrganizationKeys(request, organizationKeys); + + this.setOrganizationInformation(request, subscription.organization); + + this.setPlanInformation(request, subscription.plan); + + const response = await this.organizationApiService.createWithoutPayment(request); + + await this.apiService.refreshIdentityToken(); + + await this.syncService.fullSync(true); + + return response; + } + private async makeOrganizationKeys(): Promise { const [encryptedKey, key] = await this.keyService.makeOrgKey(); const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key); @@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setOrganizationInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: OrganizationInformation, ): void { request.name = information.name; @@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs request.initiationPath = information.initiationPath; } - private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void { + private setOrganizationKeys( + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, + keys: OrganizationKeys, + ): void { request.key = keys.encryptedKey.encryptedString; request.keys = new OrganizationKeysRequest( keys.publicKey, @@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs } private setPlanInformation( - request: OrganizationCreateRequest, + request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest, information: PlanInformation, ): void { request.planType = information.type; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 47cdbc90cfd..d36aea241d5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -38,6 +38,8 @@ export enum FeatureFlag { Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions", LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split", CriticalApps = "pm-14466-risk-insights-critical-application", + TrialPaymentOptional = "PM-8163-trial-payment", + SecurityTasks = "security-tasks", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -86,6 +88,8 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE, [FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE, [FeatureFlag.CriticalApps]: FALSE, + [FeatureFlag.TrialPaymentOptional]: FALSE, + [FeatureFlag.SecurityTasks]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts index a05eab52305..9bb4ed0a4c5 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts @@ -4,6 +4,36 @@ describe("Fido2 Utils", () => { const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]; const b64HelloWorldString = "aGVsbG8gd29ybGQ="; + describe("bufferSourceToUint8Array(..)", () => { + it("should convert an ArrayBuffer", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const out = Fido2Utils.bufferSourceToUint8Array(buffer); + expect(out).toEqual(new Uint8Array(asciiHelloWorldArray)); + }); + it("should convert an ArrayBuffer slice", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer.slice(8); + const out = Fido2Utils.bufferSourceToUint8Array(buffer); + expect(out).toEqual(new Uint8Array([114, 108, 100])); // 8th byte onwards + }); + it("should pass through an Uint8Array", () => { + const typedArray = new Uint8Array(asciiHelloWorldArray); + const out = Fido2Utils.bufferSourceToUint8Array(typedArray); + expect(out).toEqual(new Uint8Array(asciiHelloWorldArray)); + }); + it("should preserve the view of TypedArray", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const input = new Uint8Array(buffer, 8, 1); + const out = Fido2Utils.bufferSourceToUint8Array(input); + expect(out).toEqual(new Uint8Array([114])); + }); + it("should convert different TypedArrays", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const input = new Uint16Array(buffer, 8, 1); + const out = Fido2Utils.bufferSourceToUint8Array(input); + expect(out).toEqual(new Uint8Array([114, 108])); + }); + }); + describe("fromBufferToB64(...)", () => { it("should convert an ArrayBuffer to a b64 string", () => { const buffer = new Uint8Array(asciiHelloWorldArray).buffer; diff --git a/libs/common/src/platform/services/fido2/fido2-utils.ts b/libs/common/src/platform/services/fido2/fido2-utils.ts index c3c3eba246b..58034912978 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.ts @@ -1,13 +1,6 @@ export class Fido2Utils { static bufferToString(bufferSource: BufferSource): string { - let buffer: Uint8Array; - if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) { - buffer = new Uint8Array(bufferSource as ArrayBuffer); - } else { - buffer = new Uint8Array(bufferSource.buffer); - } - - return Fido2Utils.fromBufferToB64(buffer) + return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); @@ -18,12 +11,10 @@ export class Fido2Utils { } static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array { - if (bufferSource instanceof Uint8Array) { - return bufferSource; - } else if (Fido2Utils.isArrayBuffer(bufferSource)) { + if (Fido2Utils.isArrayBuffer(bufferSource)) { return new Uint8Array(bufferSource); } else { - return new Uint8Array(bufferSource.buffer); + return new Uint8Array(bufferSource.buffer, bufferSource.byteOffset, bufferSource.byteLength); } } diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index f2f1749cb62..9ab692348eb 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -102,8 +102,8 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { const boundariesHint = this.i18nService.t( "generatorBoundariesHint", - constraints.numWords.min, - constraints.numWords.max, + constraints.numWords.min?.toString(), + constraints.numWords.max?.toString(), ); this.numWordsBoundariesHint.next(boundariesHint); }); diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 677a3417b97..5f74a4840cf 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -171,10 +171,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { this.minNumber.valueChanges .pipe( map((value) => [value, value > 0] as const), - tap(([value]) => (lastMinNumber = this.numbers.value ? value : lastMinNumber)), + tap(([value, checkNumbers]) => (lastMinNumber = checkNumbers ? value : lastMinNumber)), takeUntil(this.destroyed$), ) - .subscribe(([, checked]) => this.numbers.setValue(checked, { emitEvent: false })); + .subscribe(([, checkNumbers]) => this.numbers.setValue(checkNumbers, { emitEvent: false })); let lastMinSpecial = 1; this.special.valueChanges @@ -188,10 +188,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { this.minSpecial.valueChanges .pipe( map((value) => [value, value > 0] as const), - tap(([value]) => (lastMinSpecial = this.special.value ? value : lastMinSpecial)), + tap(([value, checkSpecial]) => (lastMinSpecial = checkSpecial ? value : lastMinSpecial)), takeUntil(this.destroyed$), ) - .subscribe(([, checked]) => this.special.setValue(checked, { emitEvent: false })); + .subscribe(([, checkSpecial]) => this.special.setValue(checkSpecial, { emitEvent: false })); // `onUpdated` depends on `settings` because the UserStateSubject is asynchronous; // subscribing directly to `this.settings.valueChanges` introduces a race condition. diff --git a/package-lock.json b/package-lock.json index 1ba38d10dc8..e71e8c387d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.58", + "tldts": "6.1.60", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2" @@ -225,7 +225,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.58", + "tldts": "6.1.60", "zxcvbn": "4.4.2" }, "bin": { @@ -36093,21 +36093,21 @@ } }, "node_modules/tldts": { - "version": "6.1.58", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.58.tgz", - "integrity": "sha512-MQJrJhjHOYGYb8DobR6Y4AdDbd4TYkyQ+KBDVc5ODzs1cbrvPpfN1IemYi9jfipJ/vR1YWvrDli0hg1y19VRoA==", + "version": "6.1.60", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.60.tgz", + "integrity": "sha512-TYVHm7G9NCnhgqOsFalbX6MG1Po5F4efF+tLfoeiOGQq48Oqgwcgz8upY2R1BHWa4aDrj28RYx0dkYJ63qCFMg==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.58" + "tldts-core": "^6.1.60" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.58", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.58.tgz", - "integrity": "sha512-dR936xmhBm7AeqHIhCWwK765gZ7dFyL+IqLSFAjJbFlUXGMLCb8i2PzlzaOuWBuplBTaBYseSb565nk/ZEM0Bg==", + "version": "6.1.60", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.60.tgz", + "integrity": "sha512-XHjoxak8SFQnHnmYHb3PcnW5TZ+9ErLZemZei3azuIRhQLw4IExsVbL3VZJdHcLeNaXq6NqawgpDPpjBOg4B5g==", "license": "MIT" }, "node_modules/tmp": { diff --git a/package.json b/package.json index 368da367e85..282a63f2351 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.58", + "tldts": "6.1.60", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2"