1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Vicki League
2024-09-05 16:54:45 -04:00
37 changed files with 710 additions and 182 deletions

View File

@@ -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",

View File

@@ -86,10 +86,8 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
}
async ngOnInit(): Promise<void> {
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();
}
}

View File

@@ -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);

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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,

View File

@@ -0,0 +1,17 @@
<popup-page>
<popup-header slot="header" [pageTitle]="headerText" showBackButton></popup-header>
<tools-send-form
formId="sendForm"
[config]="config"
(sendSaved)="onSendSaved()"
[submitBtn]="submitBtn"
>
</tools-send-form>
<popup-footer slot="footer">
<button bitButton type="submit" form="sendForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }}
</button>
</popup-footer>
</popup-page>

View File

@@ -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<Record<keyof QueryParams, string>>;
/**
* 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"));
}
}
}

View File

@@ -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/" +

View File

@@ -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",

View File

@@ -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",

View File

@@ -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. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -0,0 +1,144 @@
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseFormGroup" [bitSubmit]="submitPremiumLicense">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button type="button" bitButton buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{
licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n)
}}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="onLicenseFileSelected($event)"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</form>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<app-payment-v2 [showBankAccount]="false"></app-payment-v2>
<app-tax-info></app-tax-info>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<!-- TODO: Currently incorrect - https://bitwarden.atlassian.net/browse/PM-11525 -->
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>

View File

@@ -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<boolean>;
protected addOnFormGroup = new FormGroup({
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
});
protected licenseFormGroup = new FormGroup({
file: new FormControl<File>(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<boolean> =>
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<void> => {
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<void> => {
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;
}
}

View File

@@ -69,7 +69,7 @@
<form [formGroup]="licenseForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<div class="tw-pt-2 tw-pb-1">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>

View File

@@ -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",

View File

@@ -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<void>();
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();
}
}

View File

@@ -52,6 +52,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
UpdateLicenseDialogComponent,
OffboardingSurveyComponent,
VerifyBankAccountComponent,
PaymentV2Component,
],
})
export class BillingSharedModule {}

View File

@@ -1,7 +1,7 @@
<form [formGroup]="updateLicenseForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label *ngIf="showAutomaticSyncAndManualUpload">{{ "licenseFile" | i18n }}</bit-label>
<div>
<div class="tw-pb-1 tw-pt-2">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>

View File

@@ -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({

View File

@@ -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();
}
}

View File

@@ -370,7 +370,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: AppIdServiceAbstraction,
useClass: AppIdService,
deps: [GlobalStateProvider],
deps: [OBSERVABLE_DISK_STORAGE, LogService],
}),
safeProvider({
provide: AuditServiceAbstraction,

View File

@@ -1,8 +1,4 @@
import { Observable } from "rxjs";
export abstract class AppIdService {
abstract appId$: Observable<string>;
abstract anonymousAppId$: Observable<string>;
abstract getAppId(): Promise<string>;
abstract getAnonymousAppId(): Promise<string>;
}

View File

@@ -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<string>;
let anonymousAppIdState: FakeGlobalState<string>;
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<LogService>());
});
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");
});
});
});

View File

@@ -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<string>;
anonymousAppId$: Observable<string>;
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<string> {
return await firstValueFrom(this.appId$);
this.logService.info("Retrieving application id");
return await this.getEnsuredValue(APP_ID_KEY);
}
async getAnonymousAppId(): Promise<string> {
return await firstValueFrom(this.anonymousAppId$);
return await this.getEnsuredValue(ANONYMOUS_APP_ID_KEY);
}
private async getEnsuredValue(key: string) {
let value = await this.storageService.get<string | null>(key);
if (value == null) {
value = Utils.newGuid();
await this.storageService.save(key, value);
}
return value;
}
}

View File

@@ -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 {}

View File

@@ -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) => `<div class="tw-bg-background-alt tw-p-10 tw-text-main">${story}</div>`,
@@ -60,3 +78,16 @@ export const WithinSections: Story = {
`,
}),
};
export const WithoutBorderRadius: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-layout>
<bit-card>
<p bitTypography="body1" class="!tw-mb-0">Cards used in <code>bit-layout</code> will not have a border radius</p>
</bit-card>
</bit-layout>
`,
}),
};

View File

@@ -1,5 +1,5 @@
<div
class="tw-box-border tw-overflow-auto tw-flex tw-bg-background [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-cursor-pointer [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-bg-primary-100 tw-text-main tw-border-solid tw-border-b tw-border-0 tw-rounded-lg tw-mb-1.5"
class="tw-box-border tw-overflow-auto tw-flex tw-bg-background [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-cursor-pointer [&:has(.item-main-content_button:hover,.item-main-content_a:hover)]:tw-bg-primary-100 tw-text-main tw-border-solid tw-border-b tw-border-0 [&:not(bit-layout_*)]:tw-rounded-lg tw-mb-1.5"
[ngClass]="
focusVisibleWithin()
? 'tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-primary-700 tw-border-transparent'

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./item.stories";
@@ -15,9 +15,17 @@ import { ItemModule } from "@bitwarden/components";
It is a generic container that can be used for either standalone content, an alternative to tables,
or to list nav links.
<Canvas>
<Story of={stories.Default} />
</Canvas>
<Story of={stories.Default} />
<br />
Items used within a parent `bit-layout` component will not have a border radius, since the
`bit-layout` background is white.
<Story of={stories.WithoutBorderRadius} />
<br />
<br />
## Primary Content
@@ -41,9 +49,7 @@ The content can be a button, anchor, or static container.
</bit-item>
```
<Canvas>
<Story of={stories.ContentTypes} />
</Canvas>
<Story of={stories.ContentTypes} />
### Content Slots
@@ -74,9 +80,7 @@ The content can be a button, anchor, or static container.
</bit-item>
```
<Canvas>
<Story of={stories.ContentSlots} />
</Canvas>
<Story of={stories.ContentSlots} />
## 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 `<bit-item-group>`.
<Canvas>
<Story of={stories.MultipleActionList} />
</Canvas>
<Story of={stories.MultipleActionList} />
<Canvas>
<Story of={stories.SingleActionList} />
</Canvas>
<Story of={stories.SingleActionList} />
### A11y
@@ -136,6 +136,4 @@ Use `aria-label` or `aria-labelledby` to give groups an accessible name.
### Virtual Scrolling
<Canvas>
<Story of={stories.VirtualScrolling} />
</Canvas>
<Story of={stories.VirtualScrolling} />

View File

@@ -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) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
@@ -333,3 +353,32 @@ export const VirtualScrolling: Story = {
`,
}),
};
export const WithoutBorderRadius: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-layout>
<bit-item>
<button bit-item-content>
<i slot="start" class="bwi bwi-globe tw-text-3xl tw-text-muted" aria-hidden="true"></i>
Foo
<span slot="secondary">Bar</span>
</button>
<ng-container slot="end">
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</ng-container>
</bit-item>
</bit-layout>
`,
}),
};

View File

@@ -10,6 +10,8 @@
<button
bit-item-content
appA11yTitle="{{ 'edit' | i18n }} - {{ send.name }}"
routerLink="/edit-send"
[queryParams]="{ sendId: send.id, type: send.type }"
appStopClick
type="button"
class="tw-pb-1"

View File

@@ -4,7 +4,7 @@
<li *ngFor="let attachment of cipher.attachments">
<bit-item>
<bit-item-content>
<span data-testid="file-name">{{ attachment.fileName }}</span>
<span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
</bit-item-content>
<ng-container slot="end">

View File

@@ -5,7 +5,7 @@
<bit-item-group>
<bit-item *ngFor="let attachment of cipher.attachments">
<bit-item-content>
<span data-testid="file-name">{{ attachment.fileName }}</span>
<span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
</bit-item-content>
<ng-container slot="end">

6
package-lock.json generated
View File

@@ -193,7 +193,7 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2024.8.1"
"version": "2024.8.2"
},
"apps/cli": {
"name": "@bitwarden/cli",
@@ -233,7 +233,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
"version": "2024.8.3",
"version": "2024.9.0",
"hasInstallScript": true,
"license": "GPL-3.0"
},
@@ -247,7 +247,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2024.8.2"
"version": "2024.8.3"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",