1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-23 16:13:21 +00:00

Merge branch 'main' into auth/pm-19555/defect-clicking-log-out-button

This commit is contained in:
Alec Rippberger
2025-04-18 14:41:48 -05:00
committed by GitHub
50 changed files with 615 additions and 53 deletions

View File

@@ -1255,6 +1255,7 @@ const safeProviders: SafeProvider[] = [
I18nServiceAbstraction,
OrganizationApiServiceAbstraction,
SyncService,
ConfigService,
],
}),
safeProvider({

View File

@@ -10,7 +10,7 @@
[routerLink]="['/']"
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
>
<bit-icon [icon]="logo"></bit-icon>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
<div

View File

@@ -1,5 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
@@ -59,4 +62,10 @@ export abstract class OrganizationBillingServiceAbstraction {
organizationId: string,
subscription: SubscriptionInformation,
) => Promise<void>;
/**
* Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria.
* @param organization
*/
abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean>;
}

View File

@@ -0,0 +1,149 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { KeyService } from "@bitwarden/key-management";
describe("BillingAccountProfileStateService", () => {
let apiService: jest.Mocked<ApiService>;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
let i18nService: jest.Mocked<I18nService>;
let organizationApiService: jest.Mocked<OrganizationApiService>;
let syncService: jest.Mocked<SyncService>;
let configService: jest.Mocked<ConfigService>;
let sut: OrganizationBillingService;
beforeEach(() => {
apiService = mock<ApiService>();
billingApiService = mock<BillingApiServiceAbstraction>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
organizationApiService = mock<OrganizationApiService>();
syncService = mock<SyncService>();
configService = mock<ConfigService>();
sut = new OrganizationBillingService(
apiService,
billingApiService,
keyService,
encryptService,
i18nService,
organizationApiService,
syncService,
configService,
);
});
afterEach(() => {
return jest.resetAllMocks();
});
describe("isBreadcrumbingPoliciesEnabled", () => {
it("returns false when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
});
it("returns false when organization belongs to a provider", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: true,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it("returns false when cannot edit subscription", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: false,
productTierType: ProductTierType.Teams,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it.each([
["Teams", ProductTierType.Teams],
["TeamsStarter", ProductTierType.TeamsStarter],
])("returns true when all conditions are met with %s tier", async (_, productTierType) => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: productTierType,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(true);
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM12276_BreadcrumbEventLogs,
);
});
it("returns false when product tier is not supported", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Enterprise,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it("handles all conditions false correctly", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: true,
canEditSubscription: false,
productTierType: ProductTierType.Free,
} as Organization;
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(actual).toBe(false);
});
it("verifies feature flag is only called once", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const org = {
isProviderUser: false,
canEditSubscription: true,
productTierType: ProductTierType.Teams,
} as Organization;
await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,5 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, of, switchMap } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { KeyService } from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
@@ -20,7 +25,7 @@ import {
PlanInformation,
SubscriptionInformation,
} from "../abstractions";
import { PlanType } from "../enums";
import { PlanType, ProductTierType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
import { PaymentSourceResponse } from "../models/response/payment-source.response";
@@ -40,6 +45,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
private i18nService: I18nService,
private organizationApiService: OrganizationApiService,
private syncService: SyncService,
private configService: ConfigService,
) {}
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
@@ -220,4 +226,29 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
this.setPaymentInformation(request, subscription.payment);
await this.billingApiService.restartSubscription(organizationId, request);
}
isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean> {
if (organization === null || organization === undefined) {
return of(false);
}
return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe(
switchMap((featureFlagEnabled) => {
if (!featureFlagEnabled) {
return of(false);
}
if (organization.isProviderUser || !organization.canEditSubscription) {
return of(false);
}
const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter];
const isSupportedProduct = supportedProducts.some(
(product) => product === organization.productTierType,
);
return of(isSupportedProduct);
}),
);
}
}

View File

@@ -1 +1,5 @@
// See https://contributing.bitwarden.com/architecture/clients/data-model/#view for proper use.
// View models represent the decrypted state of a corresponding Domain model.
// They typically match the Domain model but contains a decrypted string for any EncString fields.
// Don't use this to represent arbitrary component view data as that isn't what it is for.
export class View {}

View File

@@ -706,4 +706,73 @@ describe("Utils Service", () => {
});
});
});
describe("fromUtf8ToB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should handle empty string", () => {
const str = Utils.fromUtf8ToB64("");
expect(str).toBe("");
});
runInBothEnvironments("should convert a normal b64 string", () => {
const str = Utils.fromUtf8ToB64(asciiHelloWorld);
expect(str).toBe(b64HelloWorldString);
});
runInBothEnvironments("should convert various special characters", () => {
const cases = [
{ input: "»", output: "wrs=" },
{ input: "¦", output: "wqY=" },
{ input: "£", output: "wqM=" },
{ input: "é", output: "w6k=" },
{ input: "ö", output: "w7Y=" },
{ input: "»»", output: "wrvCuw==" },
];
cases.forEach((c) => {
const utfStr = c.input;
const str = Utils.fromUtf8ToB64(utfStr);
expect(str).toBe(c.output);
});
});
});
describe("fromB64ToUtf8(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should handle empty string", () => {
const str = Utils.fromB64ToUtf8("");
expect(str).toBe("");
});
runInBothEnvironments("should convert a normal b64 string", () => {
const str = Utils.fromB64ToUtf8(b64HelloWorldString);
expect(str).toBe(asciiHelloWorld);
});
runInBothEnvironments("should handle various special characters", () => {
const cases = [
{ input: "wrs=", output: "»" },
{ input: "wqY=", output: "¦" },
{ input: "wqM=", output: "£" },
{ input: "w6k=", output: "é" },
{ input: "w7Y=", output: "ö" },
{ input: "wrvCuw==", output: "»»" },
];
cases.forEach((c) => {
const b64Str = c.input;
const str = Utils.fromB64ToUtf8(b64Str);
expect(str).toBe(c.output);
});
});
});
});

View File

@@ -233,7 +233,7 @@ export class Utils {
if (Utils.isNode) {
return Buffer.from(utfStr, "utf8").toString("base64");
} else {
return decodeURIComponent(escape(Utils.global.btoa(utfStr)));
return BufferLib.from(utfStr, "utf8").toString("base64");
}
}
@@ -245,7 +245,7 @@ export class Utils {
if (Utils.isNode) {
return Buffer.from(b64Str, "base64").toString("utf8");
} else {
return decodeURIComponent(escape(Utils.global.atob(b64Str)));
return BufferLib.from(b64Str, "base64").toString("utf8");
}
}

View File

@@ -1,19 +1,23 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, HostBinding, Input } from "@angular/core";
import { Component, Input } from "@angular/core";
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
import { Icon, isIcon } from "./icon";
@Component({
selector: "bit-icon",
host: {
"[attr.aria-hidden]": "!ariaLabel",
"[attr.aria-label]": "ariaLabel",
"[innerHtml]": "innerHtml",
},
template: ``,
standalone: true,
})
export class BitIconComponent {
innerHtml: SafeHtml | null = null;
@Input() set icon(icon: Icon) {
if (!isIcon(icon)) {
this.innerHtml = "";
return;
}
@@ -21,7 +25,7 @@ export class BitIconComponent {
this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg);
}
@HostBinding() innerHtml: SafeHtml;
@Input() ariaLabel: string | undefined = undefined;
constructor(private domSanitizer: DomSanitizer) {}
}

View File

@@ -98,9 +98,19 @@ import * as stories from "./icon.stories";
```
- **HTML:**
> NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an
> `ariaLabel` is explicitly provided to the `<bit-icon>` component
```html
<bit-icon [icon]="Icons.ExampleIcon"></bit-icon>
```
With `ariaLabel`
```html
<bit-icon [icon]="Icons.ExampleIcon" [ariaLabel]="Your custom label text here"></bit-icon>
```
8. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client
which supports multiple style modes.

View File

@@ -26,5 +26,9 @@ export const Default: Story = {
mapping: GenericIcons,
control: { type: "select" },
},
ariaLabel: {
control: "text",
description: "the text used by a screen reader to describe the icon",
},
},
};

View File

@@ -7,3 +7,4 @@ export { LockComponentService, UnlockOptions } from "./lock/services/lock-compon
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
export { RemovePasswordComponent } from "./key-connector/remove-password.component";

View File

@@ -220,7 +220,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
this.exportForm.controls.vaultSelector.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(([value]) => {
.subscribe((value) => {
this.organizationId = value !== "myVault" ? value : undefined;
this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip");