-
+
-
+
-
+
{
const mockOrganizationId = "mockOrgId" as OrganizationId;
@@ -112,5 +115,34 @@ describe("ImportService", () => {
]),
);
});
+
+ it("should generate user report export items and include users with no access", async () => {
+ reportApiService.getMemberAccessData.mockImplementation(() =>
+ Promise.resolve(memberAccessWithoutAccessDetailsReportsMock),
+ );
+ const result =
+ await memberAccessReportService.generateUserReportExportItems(mockOrganizationId);
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ email: "asmith@email.com",
+ name: "Alice Smith",
+ twoStepLogin: "memberAccessReportTwoFactorEnabledTrue",
+ accountRecovery: "memberAccessReportAuthenticationEnabledTrue",
+ group: "Alice Group 1",
+ totalItems: "10",
+ }),
+ expect.objectContaining({
+ email: "rbrown@email.com",
+ name: "Robert Brown",
+ twoStepLogin: "memberAccessReportTwoFactorEnabledFalse",
+ accountRecovery: "memberAccessReportAuthenticationEnabledFalse",
+ group: "memberAccessReportNoGroup",
+ totalItems: "0",
+ }),
+ ]),
+ );
+ });
});
});
diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts
index b7ff5551e2c..029dce8a404 100644
--- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts
+++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts
@@ -65,6 +65,26 @@ export class MemberAccessReportService {
}
const exportItems = memberAccessReports.flatMap((report) => {
+ // to include users without access details
+ // which means a user has no groups, collections or items
+ if (report.accessDetails.length === 0) {
+ return [
+ {
+ email: report.email,
+ name: report.userName,
+ twoStepLogin: report.twoFactorEnabled
+ ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
+ : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
+ accountRecovery: report.accountRecoveryEnabled
+ ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue")
+ : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"),
+ group: this.i18nService.t("memberAccessReportNoGroup"),
+ collection: this.i18nService.t("memberAccessReportNoCollection"),
+ collectionPermission: this.i18nService.t("memberAccessReportNoCollectionPermission"),
+ totalItems: "0",
+ },
+ ];
+ }
const userDetails = report.accessDetails.map((detail) => {
const collectionName = collectionNameMap.get(detail.collectionName.encryptedString);
return {
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 3cce9b5357e..8e2b3409593 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -1255,6 +1255,7 @@ const safeProviders: SafeProvider[] = [
I18nServiceAbstraction,
OrganizationApiServiceAbstraction,
SyncService,
+ ConfigService,
],
}),
safeProvider({
diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html
index f31a5500b43..1e16dba82cc 100644
--- a/libs/auth/src/angular/anon-layout/anon-layout.component.html
+++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html
@@ -10,7 +10,7 @@
[routerLink]="['/']"
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
>
-
+
Promise
;
+
+ /**
+ * Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria.
+ * @param organization
+ */
+ abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable;
}
diff --git a/libs/common/src/billing/services/organization-billing.service.spec.ts b/libs/common/src/billing/services/organization-billing.service.spec.ts
new file mode 100644
index 00000000000..7b194dff637
--- /dev/null
+++ b/libs/common/src/billing/services/organization-billing.service.spec.ts
@@ -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;
+ let billingApiService: jest.Mocked;
+ let keyService: jest.Mocked;
+ let encryptService: jest.Mocked;
+ let i18nService: jest.Mocked;
+ let organizationApiService: jest.Mocked;
+ let syncService: jest.Mocked;
+ let configService: jest.Mocked;
+
+ let sut: OrganizationBillingService;
+
+ beforeEach(() => {
+ apiService = mock();
+ billingApiService = mock();
+ keyService = mock();
+ encryptService = mock();
+ i18nService = mock();
+ organizationApiService = mock();
+ syncService = mock();
+ configService = mock();
+
+ 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);
+ });
+ });
+});
diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts
index 83efbf0a30c..6622cdcdce3 100644
--- a/libs/common/src/billing/services/organization-billing.service.ts
+++ b/libs/common/src/billing/services/organization-billing.service.ts
@@ -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 {
@@ -220,4 +226,29 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
this.setPaymentInformation(request, subscription.payment);
await this.billingApiService.restartSubscription(organizationId, request);
}
+
+ isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable {
+ 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);
+ }),
+ );
+ }
}
diff --git a/libs/common/src/models/view/view.ts b/libs/common/src/models/view/view.ts
index 1f16b3d5958..2869617dca5 100644
--- a/libs/common/src/models/view/view.ts
+++ b/libs/common/src/models/view/view.ts
@@ -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 {}
diff --git a/libs/common/src/platform/misc/utils.spec.ts b/libs/common/src/platform/misc/utils.spec.ts
index 964a2a19413..818138863fb 100644
--- a/libs/common/src/platform/misc/utils.spec.ts
+++ b/libs/common/src/platform/misc/utils.spec.ts
@@ -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);
+ });
+ });
+ });
});
diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts
index ef65d2130a0..203a04851c5 100644
--- a/libs/common/src/platform/misc/utils.ts
+++ b/libs/common/src/platform/misc/utils.ts
@@ -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");
}
}
diff --git a/libs/components/src/icon/icon.component.ts b/libs/components/src/icon/icon.component.ts
index 2382d197bec..08fa25956d0 100644
--- a/libs/components/src/icon/icon.component.ts
+++ b/libs/components/src/icon/icon.component.ts
@@ -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) {}
}
diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx
index fc1c4cd3d57..6435fc24948 100644
--- a/libs/components/src/icon/icon.mdx
+++ b/libs/components/src/icon/icon.mdx
@@ -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 `` component
+
```html
```
+ With `ariaLabel`
+
+ ```html
+
+ ```
+
8. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client
which supports multiple style modes.
diff --git a/libs/components/src/icon/icon.stories.ts b/libs/components/src/icon/icon.stories.ts
index 53454567b7f..7892bdd3ec1 100644
--- a/libs/components/src/icon/icon.stories.ts
+++ b/libs/components/src/icon/icon.stories.ts
@@ -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",
+ },
},
};
diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts
index 2f98538caad..b330e390d36 100644
--- a/libs/key-management-ui/src/index.ts
+++ b/libs/key-management-ui/src/index.ts
@@ -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";
diff --git a/libs/angular/src/auth/components/remove-password.component.ts b/libs/key-management-ui/src/key-connector/remove-password.component.ts
similarity index 100%
rename from libs/angular/src/auth/components/remove-password.component.ts
rename to libs/key-management-ui/src/key-connector/remove-password.component.ts
diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts
index 4e9b4175838..71599c19ae0 100644
--- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts
+++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts
@@ -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");