diff --git a/apps/browser/package.json b/apps/browser/package.json index 433ddecd2ac..c5332a08016 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.8.1", + "version": "2024.8.2", "scripts": { "build": "cross-env MANIFEST_VERSION=3 webpack", "build:mv2": "webpack", diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index 09bfdbbc240..ea72fb61f5f 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -86,10 +86,8 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { } async ngOnInit(): Promise { + await super.ngOnInit(); if (this.showPasswordless) { - const loginEmail = await firstValueFrom(this.loginEmailService.loginEmail$); - this.formGroup.controls.email.setValue(loginEmail); - this.formGroup.controls.rememberEmail.setValue(this.loginEmailService.getRememberEmail()); await this.validateEmail(); } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 124cf6a78d8..2c48a8563e7 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -652,7 +652,7 @@ export default class MainBackground { this.kdfConfigService, ); - this.appIdService = new AppIdService(this.globalStateProvider); + this.appIdService = new AppIdService(this.storageService, this.logService); this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); this.organizationService = new OrganizationService(this.stateProvider); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 121897b0cef..5792c10c053 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.8.1", + "version": "2024.8.2", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 52e21a0936b..fe99c9e9889 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.8.1", + "version": "2024.8.2", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index aa8955035dd..7af88316842 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -59,6 +59,7 @@ import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/pass import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component"; import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component"; +import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component"; import { SendCreatedComponent } from "../tools/popup/send-v2/send-created/send-created.component"; import { SendV2Component } from "../tools/popup/send-v2/send-v2.component"; import { AboutPageV2Component } from "../tools/popup/settings/about-page/about-page-v2.component"; @@ -362,18 +363,16 @@ const routes: Routes = [ canActivate: [authGuard], data: { state: "send-type" }, }, - { + ...extensionRefreshSwap(SendAddEditComponent, SendAddEditV2Component, { path: "add-send", - component: SendAddEditComponent, canActivate: [authGuard], data: { state: "add-send" }, - }, - { + }), + ...extensionRefreshSwap(SendAddEditComponent, SendAddEditV2Component, { path: "edit-send", - component: SendAddEditComponent, canActivate: [authGuard], data: { state: "edit-send" }, - }, + }), { path: "send-created", component: SendCreatedComponent, diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html new file mode 100644 index 00000000000..3e9a8d7c50d --- /dev/null +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts new file mode 100644 index 00000000000..48e6cbb8a31 --- /dev/null +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -0,0 +1,141 @@ +import { CommonModule, Location } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormsModule } from "@angular/forms"; +import { ActivatedRoute, Params } from "@angular/router"; +import { map, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendId } from "@bitwarden/common/types/guid"; +import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/components"; +import { + DefaultSendFormConfigService, + SendFormConfig, + SendFormConfigService, + SendFormMode, +} from "@bitwarden/send-ui"; + +import { SendFormModule } from "../../../../../../../libs/tools/send/send-ui/src/send-form/send-form.module"; +import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; + +/** + * Helper class to parse query parameters for the AddEdit route. + */ +class QueryParams { + constructor(params: Params) { + this.sendId = params.sendId; + this.type = parseInt(params.type, 10); + } + + /** + * The ID of the send to edit, empty when it's a new Send + */ + sendId?: SendId; + + /** + * The type of send to create. + */ + type: SendType; +} + +export type AddEditQueryParams = Partial>; + +/** + * Component for adding or editing a send item. + */ +@Component({ + selector: "tools-send-add-edit", + templateUrl: "send-add-edit.component.html", + standalone: true, + providers: [{ provide: SendFormConfigService, useClass: DefaultSendFormConfigService }], + imports: [ + CommonModule, + SearchModule, + JslibModule, + FormsModule, + ButtonModule, + PopupPageComponent, + PopupHeaderComponent, + PopupFooterComponent, + SendFormModule, + AsyncActionsModule, + ], +}) +export class SendAddEditComponent { + /** + * The header text for the component. + */ + headerText: string; + + /** + * The configuration for the send form. + */ + config: SendFormConfig; + + constructor( + private route: ActivatedRoute, + private location: Location, + private i18nService: I18nService, + private addEditFormConfigService: SendFormConfigService, + ) { + this.subscribeToParams(); + } + + /** + * Handles the event when the send is saved. + */ + onSendSaved() { + this.location.back(); + } + + /** + * Subscribes to the route query parameters and builds the configuration based on the parameters. + */ + subscribeToParams(): void { + this.route.queryParams + .pipe( + takeUntilDestroyed(), + map((params) => new QueryParams(params)), + switchMap(async (params) => { + let mode: SendFormMode; + if (params.sendId == null) { + mode = "add"; + } else { + mode = "edit"; + } + const config = await this.addEditFormConfigService.buildConfig( + mode, + params.sendId, + params.type, + ); + return config; + }), + ) + .subscribe((config) => { + this.config = config; + this.headerText = this.getHeaderText(config.mode, config.sendType); + }); + } + + /** + * Gets the header text based on the mode and type. + * @param mode The mode of the send form. + * @param type The type of the send form. + * @returns The header text. + */ + private getHeaderText(mode: SendFormMode, type: SendType) { + const headerKey = + mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader"; + + switch (type) { + case SendType.Text: + return this.i18nService.t(headerKey, this.i18nService.t("sendTypeText")); + case SendType.File: + return this.i18nService.t(headerKey, this.i18nService.t("sendTypeFile")); + } + } +} diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index fb77e41a4b6..be1b1cc9e2b 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -413,7 +413,7 @@ export class ServiceContainer { this.kdfConfigService, ); - this.appIdService = new AppIdService(this.globalStateProvider); + this.appIdService = new AppIdService(this.storageService, this.logService); const customUserAgent = "Bitwarden_CLI/" + diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 68be97abfe1..70a0d9cb8c2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2024.8.3", + "version": "2024.9.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index a37c185d170..6823bddceb8 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.8.3", + "version": "2024.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.8.3", + "version": "2024.9.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi", diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index df1f7f70d41..18a046d5bce 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2024.8.3", + "version": "2024.9.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/package.json b/apps/web/package.json index 391cd4b5cc6..de153ea1a23 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.8.2", + "version": "2024.8.3", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index ff45ca75ac6..585d9b418c1 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,10 +1,14 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { PaymentMethodComponent } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; -import { PremiumComponent } from "./premium.component"; +import { PremiumV2Component } from "./premium/premium-v2.component"; +import { PremiumComponent } from "./premium/premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -20,11 +24,15 @@ const routes: Routes = [ component: UserSubscriptionComponent, data: { titleId: "premiumMembership" }, }, - { - path: "premium", - component: PremiumComponent, - data: { titleId: "goPremium" }, - }, + ...featureFlaggedRoute({ + defaultComponent: PremiumComponent, + flaggedComponent: PremiumV2Component, + featureFlag: FeatureFlag.AC2476_DeprecateStripeSourcesAPI, + routeOptions: { + path: "premium", + data: { titleId: "goPremium" }, + }, + }), { path: "payment-method", component: PaymentMethodComponent, diff --git a/apps/web/src/app/billing/individual/individual-billing.module.ts b/apps/web/src/app/billing/individual/individual-billing.module.ts index dbae28858f8..0dbbc8c6837 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -5,7 +5,8 @@ import { BillingSharedModule } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { IndividualBillingRoutingModule } from "./individual-billing-routing.module"; -import { PremiumComponent } from "./premium.component"; +import { PremiumV2Component } from "./premium/premium-v2.component"; +import { PremiumComponent } from "./premium/premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -16,6 +17,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component"; BillingHistoryViewComponent, UserSubscriptionComponent, PremiumComponent, + PremiumV2Component, ], }) export class IndividualBillingModule {} diff --git a/apps/web/src/app/billing/individual/premium/premium-v2.component.html b/apps/web/src/app/billing/individual/premium/premium-v2.component.html new file mode 100644 index 00000000000..bdf6ff87d19 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/premium-v2.component.html @@ -0,0 +1,144 @@ + +

{{ "goPremium" | i18n }}

+ + {{ "alreadyPremiumFromOrg" | i18n }} + + +

{{ "premiumUpgradeUnlockFeatures" | i18n }}

+
    +
  • + + {{ "premiumSignUpStorage" | i18n }} +
  • +
  • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
  • +
  • + + {{ "premiumSignUpEmergency" | i18n }} +
  • +
  • + + {{ "premiumSignUpReports" | i18n }} +
  • +
  • + + {{ "premiumSignUpTotp" | i18n }} +
  • +
  • + + {{ "premiumSignUpSupport" | i18n }} +
  • +
  • + + {{ "premiumSignUpFuture" | i18n }} +
  • +
+

+ {{ + "premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount + }} + + {{ "bitwardenFamiliesPlan" | i18n }} + +

+ + {{ "purchasePremium" | i18n }} + +
+
+ +

{{ "uploadLicenseFilePremium" | i18n }}

+
+ + {{ "licenseFile" | i18n }} +
+ + {{ + licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n) + }} +
+ + {{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }} +
+ +
+
+
+ +

{{ "addons" | i18n }}

+
+ + {{ "additionalStorageGb" | i18n }} + + {{ + "additionalStorageIntervalDesc" + | i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) + }} + +
+
+ +

{{ "summary" | i18n }}

+ {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
+ {{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB × + {{ storageGBPrice | currency: "$" }} = + {{ additionalStorageCost | currency: "$" }} +
+
+ +

{{ "paymentInformation" | i18n }}

+ + +
+
+ {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} + + {{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }} +
+
+
+

+ {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }} +

+ +
+
diff --git a/apps/web/src/app/billing/individual/premium/premium-v2.component.ts b/apps/web/src/app/billing/individual/premium/premium-v2.component.ts new file mode 100644 index 00000000000..cf66dac2f76 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/premium-v2.component.ts @@ -0,0 +1,164 @@ +import { Component, ViewChild } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, concatMap, from, Observable, of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ToastService } from "@bitwarden/components"; + +import { PaymentV2Component } from "../../shared/payment/payment-v2.component"; +import { TaxInfoComponent } from "../../shared/tax-info.component"; + +@Component({ + templateUrl: "./premium-v2.component.html", +}) +export class PremiumV2Component { + @ViewChild(PaymentV2Component) paymentComponent: PaymentV2Component; + @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; + + protected hasPremiumFromAnyOrganization$: Observable; + + protected addOnFormGroup = new FormGroup({ + additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), + }); + + protected licenseFormGroup = new FormGroup({ + file: new FormControl(null, [Validators.required]), + }); + + protected cloudWebVaultURL: string; + protected isSelfHost = false; + + protected readonly familyPlanMaxUserCount = 6; + protected readonly premiumPrice = 10; + protected readonly storageGBPrice = 4; + + constructor( + private activatedRoute: ActivatedRoute, + private apiService: ApiService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private router: Router, + private syncService: SyncService, + private toastService: ToastService, + private tokenService: TokenService, + ) { + this.isSelfHost = this.platformUtilsService.isSelfHost(); + + this.hasPremiumFromAnyOrganization$ = + this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$; + + combineLatest([ + this.billingAccountProfileStateService.hasPremiumPersonally$, + this.environmentService.cloudWebVaultUrl$, + ]) + .pipe( + takeUntilDestroyed(), + concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => { + if (hasPremiumPersonally) { + return from(this.navigateToSubscriptionPage()); + } + + this.cloudWebVaultURL = cloudWebVaultURL; + return of(true); + }), + ) + .subscribe(); + } + + finalizeUpgrade = async () => { + await this.apiService.refreshIdentityToken(); + await this.syncService.fullSync(true); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("premiumUpdated"), + }); + await this.navigateToSubscriptionPage(); + }; + + navigateToSubscriptionPage = (): Promise => + this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); + + onLicenseFileSelected = (event: Event): void => { + const element = event.target as HTMLInputElement; + this.licenseFormGroup.value.file = element.files.length > 0 ? element.files[0] : null; + }; + + submitPremiumLicense = async (): Promise => { + this.licenseFormGroup.markAllAsTouched(); + + if (this.licenseFormGroup.invalid) { + return this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("selectFile"), + }); + } + + const emailVerified = await this.tokenService.getEmailVerified(); + if (!emailVerified) { + return this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("verifyEmailFirst"), + }); + } + + const formData = new FormData(); + formData.append("license", this.licenseFormGroup.value.file); + + await this.apiService.postAccountLicense(formData); + await this.finalizeUpgrade(); + }; + + submitPayment = async (): Promise => { + this.taxInfoComponent.taxFormGroup.markAllAsTouched(); + if (this.taxInfoComponent.taxFormGroup.invalid) { + return; + } + + const { type, token } = await this.paymentComponent.tokenize(); + + const formData = new FormData(); + formData.append("paymentMethodType", type.toString()); + formData.append("paymentToken", token); + formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString()); + formData.append("country", this.taxInfoComponent.country); + formData.append("postalCode", this.taxInfoComponent.postalCode); + + await this.apiService.postPremium(formData); + await this.finalizeUpgrade(); + }; + + protected get additionalStorageCost(): number { + return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage; + } + + protected get estimatedTax(): number { + return this.taxInfoComponent?.taxRate != null + ? (this.taxInfoComponent.taxRate / 100) * this.subtotal + : 0; + } + + protected get premiumURL(): string { + return `${this.cloudWebVaultURL}/#/settings/subscription/premium`; + } + + protected get subtotal(): number { + return this.premiumPrice + this.additionalStorageCost; + } + + protected get total(): number { + return this.subtotal + this.estimatedTax; + } +} diff --git a/apps/web/src/app/billing/individual/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html similarity index 99% rename from apps/web/src/app/billing/individual/premium.component.html rename to apps/web/src/app/billing/individual/premium/premium.component.html index ae95475f1c6..8b848b48dab 100644 --- a/apps/web/src/app/billing/individual/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -69,7 +69,7 @@
{{ "licenseFile" | i18n }} -
+
diff --git a/apps/web/src/app/billing/individual/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts similarity index 98% rename from apps/web/src/app/billing/individual/premium.component.ts rename to apps/web/src/app/billing/individual/premium/premium.component.ts index 79a5c5e2edd..c45b6b882d4 100644 --- a/apps/web/src/app/billing/individual/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -13,7 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; -import { PaymentComponent, TaxInfoComponent } from "../shared"; +import { PaymentComponent, TaxInfoComponent } from "../../shared"; @Component({ templateUrl: "premium.component.html", diff --git a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts index 436372c049b..4fb9ae386a1 100644 --- a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts +++ b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts @@ -1,6 +1,6 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/organization-subscription-update.request"; @@ -11,7 +11,7 @@ import { ToastService } from "@bitwarden/components"; selector: "app-adjust-subscription", templateUrl: "adjust-subscription.component.html", }) -export class AdjustSubscription { +export class AdjustSubscription implements OnInit, OnDestroy { @Input() organizationId: string; @Input() maxAutoscaleSeats: number; @Input() currentSeatCount: number; @@ -19,6 +19,8 @@ export class AdjustSubscription { @Input() interval = "year"; @Output() onAdjusted = new EventEmitter(); + private destroy$ = new Subject(); + adjustSubscriptionForm = this.formBuilder.group({ newSeatCount: [0, [Validators.min(0)]], limitSubscription: [false], @@ -30,30 +32,25 @@ export class AdjustSubscription { private organizationApiService: OrganizationApiServiceAbstraction, private formBuilder: FormBuilder, private toastService: ToastService, - ) { + ) {} + + ngOnInit() { + this.adjustSubscriptionForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + const maxAutoscaleSeatsControl = this.adjustSubscriptionForm.controls.newMaxSeats; + + if (value.limitSubscription) { + maxAutoscaleSeatsControl.setValidators([Validators.min(value.newSeatCount)]); + maxAutoscaleSeatsControl.enable({ emitEvent: false }); + } else { + maxAutoscaleSeatsControl.disable({ emitEvent: false }); + } + }); + this.adjustSubscriptionForm.patchValue({ newSeatCount: this.currentSeatCount, - limitSubscription: this.maxAutoscaleSeats != null, newMaxSeats: this.maxAutoscaleSeats, + limitSubscription: this.maxAutoscaleSeats != null, }); - this.adjustSubscriptionForm - .get("limitSubscription") - .valueChanges.pipe(takeUntilDestroyed()) - .subscribe((value: boolean) => { - if (value) { - this.adjustSubscriptionForm - .get("newMaxSeats") - .addValidators([ - Validators.min( - this.adjustSubscriptionForm.value.newSeatCount == null - ? 1 - : this.adjustSubscriptionForm.value.newSeatCount, - ), - Validators.required, - ]); - } - this.adjustSubscriptionForm.get("newMaxSeats").updateValueAndValidity(); - }); } submit = async () => { @@ -99,4 +96,9 @@ export class AdjustSubscription { get limitSubscription(): boolean { return this.adjustSubscriptionForm.value.limitSubscription; } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index c9b3f2de855..b966729c1df 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -52,6 +52,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac UpdateLicenseDialogComponent, OffboardingSurveyComponent, VerifyBankAccountComponent, + PaymentV2Component, ], }) export class BillingSharedModule {} diff --git a/apps/web/src/app/billing/shared/update-license.component.html b/apps/web/src/app/billing/shared/update-license.component.html index 938179469e4..ea0818389e4 100644 --- a/apps/web/src/app/billing/shared/update-license.component.html +++ b/apps/web/src/app/billing/shared/update-license.component.html @@ -1,7 +1,7 @@ {{ "licenseFile" | i18n }} -
+
diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 460af8623e6..887c8fb626a 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -32,6 +32,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; @@ -41,6 +42,7 @@ import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwar import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; +import { AppIdService as DefaultAppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; // eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; @@ -207,6 +209,11 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, ], }), + safeProvider({ + provide: AppIdService, + useClass: DefaultAppIdService, + deps: [OBSERVABLE_DISK_LOCAL_STORAGE, LogService], + }), ]; @NgModule({ diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 3b927a05716..b798a8df0b4 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -1,8 +1,8 @@ import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, firstValueFrom } from "rxjs"; -import { take, takeUntil } from "rxjs/operators"; +import { Subject, firstValueFrom, of } from "rxjs"; +import { switchMap, take, takeUntil } from "rxjs/operators"; import { LoginStrategyServiceAbstraction, @@ -99,20 +99,31 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, } async ngOnInit() { - this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { - if (!params) { - return; - } + this.route?.queryParams + .pipe( + switchMap((params) => { + if (!params) { + // If no params,loadEmailSettings from state + return this.loadEmailSettings(); + } - const queryParamsEmail = params.email; + const queryParamsEmail = params.email; - if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { - this.formGroup.controls.email.setValue(queryParamsEmail); - this.paramEmailSet = true; - } - }); + if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { + this.formGroup.controls.email.setValue(queryParamsEmail); + this.paramEmailSet = true; + } - if (!this.paramEmailSet) { + // If paramEmailSet is false, loadEmailSettings from state + return this.paramEmailSet ? of(null) : this.loadEmailSettings(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + // Backup check to handle unknown case where activatedRoute is not available + // This shouldn't happen under normal circumstances + if (!this.route) { await this.loadEmailSettings(); } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 7b9c33f3d0a..ab52ab8e433 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -370,7 +370,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: AppIdServiceAbstraction, useClass: AppIdService, - deps: [GlobalStateProvider], + deps: [OBSERVABLE_DISK_STORAGE, LogService], }), safeProvider({ provide: AuditServiceAbstraction, diff --git a/libs/common/src/platform/abstractions/app-id.service.ts b/libs/common/src/platform/abstractions/app-id.service.ts index c2c1a23ef5e..7fb02ee9e09 100644 --- a/libs/common/src/platform/abstractions/app-id.service.ts +++ b/libs/common/src/platform/abstractions/app-id.service.ts @@ -1,8 +1,4 @@ -import { Observable } from "rxjs"; - export abstract class AppIdService { - abstract appId$: Observable; - abstract anonymousAppId$: Observable; abstract getAppId(): Promise; abstract getAnonymousAppId(): Promise; } diff --git a/libs/common/src/platform/services/app-id.service.spec.ts b/libs/common/src/platform/services/app-id.service.spec.ts index 62806204db0..23691769bf2 100644 --- a/libs/common/src/platform/services/app-id.service.spec.ts +++ b/libs/common/src/platform/services/app-id.service.spec.ts @@ -1,19 +1,18 @@ -import { FakeGlobalState, FakeGlobalStateProvider, ObservableTracker } from "../../../spec"; +import { mock } from "jest-mock-extended"; + +import { FakeStorageService } from "../../../spec"; +import { LogService } from "../abstractions/log.service"; import { Utils } from "../misc/utils"; import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service"; describe("AppIdService", () => { - let globalStateProvider: FakeGlobalStateProvider; - let appIdState: FakeGlobalState; - let anonymousAppIdState: FakeGlobalState; + let fakeStorageService: FakeStorageService; let sut: AppIdService; beforeEach(() => { - globalStateProvider = new FakeGlobalStateProvider(); - appIdState = globalStateProvider.getFake(APP_ID_KEY); - anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY); - sut = new AppIdService(globalStateProvider); + fakeStorageService = new FakeStorageService(); + sut = new AppIdService(fakeStorageService, mock()); }); afterEach(() => { @@ -22,7 +21,7 @@ describe("AppIdService", () => { describe("getAppId", () => { it("returns the existing appId when it exists", async () => { - appIdState.stateSubject.next("existingAppId"); + fakeStorageService.internalUpdateStore({ [APP_ID_KEY]: "existingAppId" }); const appId = await sut.getAppId(); @@ -30,7 +29,7 @@ describe("AppIdService", () => { }); it("creates a new appId only once", async () => { - appIdState.stateSubject.next(null); + fakeStorageService.internalUpdateStore({ [APP_ID_KEY]: null }); const appIds: string[] = []; const promises = [async () => appIds.push(await sut.getAppId())]; @@ -41,7 +40,7 @@ describe("AppIdService", () => { }); it.each([null, undefined])("returns a new appId when %s", async (value) => { - appIdState.stateSubject.next(value); + fakeStorageService.internalUpdateStore({ [APP_ID_KEY]: value }); const appId = await sut.getAppId(); @@ -49,27 +48,17 @@ describe("AppIdService", () => { }); it.each([null, undefined])("stores the new guid when %s", async (value) => { - appIdState.stateSubject.next(value); + fakeStorageService.internalUpdateStore({ [APP_ID_KEY]: value }); const appId = await sut.getAppId(); - expect(appIdState.nextMock).toHaveBeenCalledWith(appId); - }); - - it("emits only once when creating a new appId", async () => { - appIdState.stateSubject.next(null); - - const tracker = new ObservableTracker(sut.appId$); - const appId = await sut.getAppId(); - - expect(tracker.emissions).toEqual([appId]); - await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded"); + expect(fakeStorageService.mock.save).toHaveBeenCalledWith(APP_ID_KEY, appId, undefined); }); }); describe("getAnonymousAppId", () => { it("returns the existing appId when it exists", async () => { - anonymousAppIdState.stateSubject.next("existingAppId"); + fakeStorageService.internalUpdateStore({ [ANONYMOUS_APP_ID_KEY]: "existingAppId" }); const appId = await sut.getAnonymousAppId(); @@ -77,7 +66,7 @@ describe("AppIdService", () => { }); it("creates a new anonymousAppId only once", async () => { - anonymousAppIdState.stateSubject.next(null); + fakeStorageService.internalUpdateStore({ [ANONYMOUS_APP_ID_KEY]: null }); const appIds: string[] = []; const promises = [async () => appIds.push(await sut.getAnonymousAppId())]; @@ -88,7 +77,7 @@ describe("AppIdService", () => { }); it.each([null, undefined])("returns a new appId when it does not exist", async (value) => { - anonymousAppIdState.stateSubject.next(value); + fakeStorageService.internalUpdateStore({ [ANONYMOUS_APP_ID_KEY]: value }); const appId = await sut.getAnonymousAppId(); @@ -98,22 +87,16 @@ describe("AppIdService", () => { it.each([null, undefined])( "stores the new guid when it an existing one is not found", async (value) => { - anonymousAppIdState.stateSubject.next(value); + fakeStorageService.internalUpdateStore({ [ANONYMOUS_APP_ID_KEY]: value }); const appId = await sut.getAnonymousAppId(); - expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId); + expect(fakeStorageService.mock.save).toHaveBeenCalledWith( + ANONYMOUS_APP_ID_KEY, + appId, + undefined, + ); }, ); - - it("emits only once when creating a new anonymousAppId", async () => { - anonymousAppIdState.stateSubject.next(null); - - const tracker = new ObservableTracker(sut.anonymousAppId$); - const appId = await sut.getAnonymousAppId(); - - expect(tracker.emissions).toEqual([appId]); - await expect(tracker.pauseUntilReceived(2, 50)).rejects.toThrow("Timeout exceeded"); - }); }); }); diff --git a/libs/common/src/platform/services/app-id.service.ts b/libs/common/src/platform/services/app-id.service.ts index 3578d96c409..7b42b71bea5 100644 --- a/libs/common/src/platform/services/app-id.service.ts +++ b/libs/common/src/platform/services/app-id.service.ts @@ -1,59 +1,34 @@ -import { Observable, concatMap, distinctUntilChanged, firstValueFrom, share } from "rxjs"; - import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service"; +import { LogService } from "../abstractions/log.service"; +import { AbstractStorageService } from "../abstractions/storage.service"; import { Utils } from "../misc/utils"; -import { APPLICATION_ID_DISK, GlobalStateProvider, KeyDefinition } from "../state"; -export const APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "appId", { - deserializer: (value: string) => value, - cleanupDelayMs: 0, - debug: { - enableRetrievalLogging: true, - enableUpdateLogging: true, - }, -}); -export const ANONYMOUS_APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "anonymousAppId", { - deserializer: (value: string) => value, -}); +export const APP_ID_KEY = "global_applicationId_appId"; +export const ANONYMOUS_APP_ID_KEY = "global_applicationId_appId"; export class AppIdService implements AppIdServiceAbstraction { - appId$: Observable; - anonymousAppId$: Observable; - - constructor(globalStateProvider: GlobalStateProvider) { - const appIdState = globalStateProvider.get(APP_ID_KEY); - const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY); - this.appId$ = appIdState.state$.pipe( - concatMap(async (appId) => { - if (!appId) { - return await appIdState.update(() => Utils.newGuid(), { - shouldUpdate: (v) => v == null, - }); - } - return appId; - }), - distinctUntilChanged(), - share(), - ); - this.anonymousAppId$ = anonymousAppIdState.state$.pipe( - concatMap(async (appId) => { - if (!appId) { - return await anonymousAppIdState.update(() => Utils.newGuid(), { - shouldUpdate: (v) => v == null, - }); - } - return appId; - }), - distinctUntilChanged(), - share(), - ); - } + constructor( + private readonly storageService: AbstractStorageService, + private readonly logService: LogService, + ) {} async getAppId(): Promise { - return await firstValueFrom(this.appId$); + this.logService.info("Retrieving application id"); + return await this.getEnsuredValue(APP_ID_KEY); } async getAnonymousAppId(): Promise { - return await firstValueFrom(this.anonymousAppId$); + return await this.getEnsuredValue(ANONYMOUS_APP_ID_KEY); + } + + private async getEnsuredValue(key: string) { + let value = await this.storageService.get(key); + + if (value == null) { + value = Utils.newGuid(); + await this.storageService.save(key, value); + } + + return value; } } diff --git a/libs/components/src/card/card.component.ts b/libs/components/src/card/card.component.ts index da61d536642..3aaed26d8d0 100644 --- a/libs/components/src/card/card.component.ts +++ b/libs/components/src/card/card.component.ts @@ -9,7 +9,7 @@ import { ChangeDetectionStrategy, Component } from "@angular/core"; changeDetection: ChangeDetectionStrategy.OnPush, host: { class: - "tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 tw-rounded-lg tw-py-4 tw-px-3", + "tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 [&:not(bit-layout_*)]:tw-rounded-lg tw-py-4 tw-px-3", }, }) export class CardComponent {} diff --git a/libs/components/src/card/card.stories.ts b/libs/components/src/card/card.stories.ts index 702a8aeb631..b33f5f4a198 100644 --- a/libs/components/src/card/card.stories.ts +++ b/libs/components/src/card/card.stories.ts @@ -1,7 +1,12 @@ +import { RouterTestingModule } from "@angular/router/testing"; import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { LayoutComponent } from "../layout"; import { SectionComponent } from "../section"; import { TypographyModule } from "../typography"; +import { I18nMockService } from "../utils/i18n-mock.service"; import { CardComponent } from "./card.component"; @@ -10,7 +15,20 @@ export default { component: CardComponent, decorators: [ moduleMetadata({ - imports: [TypographyModule, SectionComponent], + imports: [TypographyModule, SectionComponent, LayoutComponent, RouterTestingModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + toggleSideNavigation: "Toggle side navigation", + skipToContent: "Skip to content", + submenu: "submenu", + toggleCollapse: "toggle collapse", + }); + }, + }, + ], }), componentWrapperDecorator( (story) => `
${story}
`, @@ -60,3 +78,16 @@ export const WithinSections: Story = { `, }), }; + +export const WithoutBorderRadius: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + +

Cards used in bit-layout will not have a border radius

+
+
+ `, + }), +}; diff --git a/libs/components/src/item/item.component.html b/libs/components/src/item/item.component.html index 130b2984752..68afb0bbb54 100644 --- a/libs/components/src/item/item.component.html +++ b/libs/components/src/item/item.component.html @@ -1,5 +1,5 @@
- - + + +
+ +Items used within a parent `bit-layout` component will not have a border radius, since the +`bit-layout` background is white. + + + +
+
## Primary Content @@ -41,9 +49,7 @@ The content can be a button, anchor, or static container. ``` - - - + ### Content Slots @@ -74,9 +80,7 @@ The content can be a button, anchor, or static container. ``` - - - + ## Secondary Actions @@ -109,13 +113,9 @@ Actions are commonly icon buttons or badge buttons. Groups of items can be associated by wrapping them in the ``. - - - + - - - + ### A11y @@ -136,6 +136,4 @@ Use `aria-label` or `aria-labelledby` to give groups an accessible name. ### Virtual Scrolling - - - + diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts index cea19d8ec17..75333082ec8 100644 --- a/libs/components/src/item/item.stories.ts +++ b/libs/components/src/item/item.stories.ts @@ -1,12 +1,17 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; +import { RouterTestingModule } from "@angular/router/testing"; import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { A11yGridDirective } from "../a11y/a11y-grid.directive"; import { AvatarModule } from "../avatar"; import { BadgeModule } from "../badge"; import { IconButtonModule } from "../icon-button"; +import { LayoutComponent } from "../layout"; import { TypographyModule } from "../typography"; +import { I18nMockService } from "../utils/i18n-mock.service"; import { ItemActionComponent } from "./item-action.component"; import { ItemContentComponent } from "./item-content.component"; @@ -29,6 +34,21 @@ export default { ItemContentComponent, A11yGridDirective, ScrollingModule, + LayoutComponent, + RouterTestingModule, + ], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + toggleSideNavigation: "Toggle side navigation", + skipToContent: "Skip to content", + submenu: "submenu", + toggleCollapse: "toggle collapse", + }); + }, + }, ], }), componentWrapperDecorator((story) => `
${story}
`), @@ -333,3 +353,32 @@ export const VirtualScrolling: Story = { `, }), }; + +export const WithoutBorderRadius: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + + + + + + + + + + + + + + `, + }), +}; diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html index 7745e4d6f88..5502422a029 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -10,6 +10,8 @@