-
+
-
+
-
+
-
{{ "generatingRiskInsights" | i18n }}
+
{{ "generatingYourRiskInsights" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html
index 397e2a630de..2d5693dad54 100644
--- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html
+++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html
@@ -8,6 +8,7 @@
{{ "reviewAtRiskPasswords" | i18n }}
{
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/admin-console/src/common/collections/services/default-collection.service.ts b/libs/admin-console/src/common/collections/services/default-collection.service.ts
index da50a25886e..1ae58d3eef3 100644
--- a/libs/admin-console/src/common/collections/services/default-collection.service.ts
+++ b/libs/admin-console/src/common/collections/services/default-collection.service.ts
@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
-import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
+import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { Jsonify } from "type-fest";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -8,10 +8,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
ActiveUserState,
- StateProvider,
COLLECTION_DATA,
DeriveDefinition,
DerivedState,
+ StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -84,6 +84,7 @@ export class DefaultCollectionService implements CollectionService {
switchMap(([userId, collectionData]) =>
combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]),
),
+ shareReplay({ refCount: false, bufferSize: 1 }),
);
this.decryptedCollectionDataState = this.stateProvider.getDerived(
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/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts
index 2393863bb5f..b9defa8383d 100644
--- a/libs/angular/src/vault/components/add-edit.component.ts
+++ b/libs/angular/src/vault/components/add-edit.component.ts
@@ -422,10 +422,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.encryptCipher(activeUserId);
+
try {
this.formPromise = this.saveCipher(cipher);
- await this.formPromise;
- this.cipher.id = cipher.id;
+ const savedCipher = await this.formPromise;
+
+ // Reset local cipher from the saved cipher returned from the server
+ this.cipher = await savedCipher.decrypt(
+ await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId),
+ );
this.toastService.showToast({
variant: "success",
title: null,
diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts
index f7280cb74b3..852302cc0c4 100644
--- a/libs/angular/src/vault/components/vault-items.component.ts
+++ b/libs/angular/src/vault/components/vault-items.component.ts
@@ -1,13 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
-import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
+import {
+ BehaviorSubject,
+ Subject,
+ combineLatest,
+ filter,
+ from,
+ of,
+ switchMap,
+ takeUntil,
+} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
-import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -21,17 +30,17 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
loaded = false;
ciphers: CipherView[] = [];
- filter: (cipher: CipherView) => boolean = null;
deleted = false;
organization: Organization;
protected searchPending = false;
- private userId: UserId;
+ /** Construct filters as an observable so it can be appended to the cipher stream. */
+ private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null);
private destroy$ = new Subject();
- private searchTimeout: any = null;
private isSearchable: boolean = false;
private _searchText$ = new BehaviorSubject("");
+
get searchText() {
return this._searchText$.value;
}
@@ -39,18 +48,28 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this._searchText$.next(value);
}
+ get filter() {
+ return this._filter$.value;
+ }
+
+ set filter(value: (cipher: CipherView) => boolean | null) {
+ this._filter$.next(value);
+ }
+
constructor(
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
- ) {}
+ ) {
+ this.subscribeToCiphers();
+ }
async ngOnInit() {
- this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
-
- this._searchText$
+ combineLatest([getUserId(this.accountService.activeAccount$), this._searchText$])
.pipe(
- switchMap((searchText) => from(this.searchService.isSearchable(this.userId, searchText))),
+ switchMap(([userId, searchText]) =>
+ from(this.searchService.isSearchable(userId, searchText)),
+ ),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {
@@ -80,23 +99,6 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
this.filter = filter;
- await this.search(null);
- }
-
- async search(timeout: number = null, indexedCiphers?: CipherView[]) {
- this.searchPending = false;
- if (this.searchTimeout != null) {
- clearTimeout(this.searchTimeout);
- }
- if (timeout == null) {
- await this.doSearch(indexedCiphers);
- return;
- }
- this.searchPending = true;
- this.searchTimeout = setTimeout(async () => {
- await this.doSearch(indexedCiphers);
- this.searchPending = false;
- }, timeout);
}
selectCipher(cipher: CipherView) {
@@ -121,25 +123,44 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
- protected async doSearch(indexedCiphers?: CipherView[], userId?: UserId) {
- // Get userId from activeAccount if not provided from parent stream
- if (!userId) {
- userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
- }
+ /**
+ * Creates stream of dependencies that results in the list of ciphers to display
+ * within the vault list.
+ *
+ * Note: This previously used promises but race conditions with how the ciphers were
+ * stored in electron. Using observables is more reliable as fresh values will always
+ * cascade through the components.
+ */
+ private subscribeToCiphers() {
+ getUserId(this.accountService.activeAccount$)
+ .pipe(
+ switchMap((userId) =>
+ combineLatest([
+ this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
+ this.cipherService.failedToDecryptCiphers$(userId),
+ this._searchText$,
+ this._filter$,
+ of(userId),
+ ]),
+ ),
+ switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => {
+ let allCiphers = indexedCiphers ?? [];
+ const _failedCiphers = failedCiphers ?? [];
- indexedCiphers =
- indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$(userId)));
+ allCiphers = [..._failedCiphers, ...allCiphers];
- const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$(userId));
- if (failedCiphers != null && failedCiphers.length > 0) {
- indexedCiphers = [...failedCiphers, ...indexedCiphers];
- }
-
- this.ciphers = await this.searchService.searchCiphers(
- this.userId,
- this.searchText,
- [this.filter, this.deletedFilter],
- indexedCiphers,
- );
+ return this.searchService.searchCiphers(
+ userId,
+ searchText,
+ [filter, this.deletedFilter],
+ allCiphers,
+ );
+ }),
+ takeUntilDestroyed(),
+ )
+ .subscribe((ciphers) => {
+ this.ciphers = ciphers;
+ this.loaded = true;
+ });
}
}
diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts
index 0dda3c593b7..6b6f24f4217 100644
--- a/libs/angular/src/vault/components/view.component.ts
+++ b/libs/angular/src/vault/components/view.component.ts
@@ -11,7 +11,17 @@ import {
OnInit,
Output,
} from "@angular/core";
-import { filter, firstValueFrom, map, Observable } from "rxjs";
+import {
+ BehaviorSubject,
+ combineLatest,
+ filter,
+ firstValueFrom,
+ map,
+ Observable,
+ of,
+ switchMap,
+ tap,
+} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
@@ -46,11 +56,22 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { PasswordRepromptService } from "@bitwarden/vault";
-const BroadcasterSubscriptionId = "ViewComponent";
+const BroadcasterSubscriptionId = "BaseViewComponent";
@Directive()
export class ViewComponent implements OnDestroy, OnInit {
- @Input() cipherId: string;
+ /** Observable of cipherId$ that will update each time the `Input` updates */
+ private _cipherId$ = new BehaviorSubject(null);
+
+ @Input()
+ set cipherId(value: string) {
+ this._cipherId$.next(value);
+ }
+
+ get cipherId(): string {
+ return this._cipherId$.getValue();
+ }
+
@Input() collectionId: string;
@Output() onEditCipher = new EventEmitter();
@Output() onCloneCipher = new EventEmitter();
@@ -126,13 +147,30 @@ export class ViewComponent implements OnDestroy, OnInit {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
- await this.load();
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
+
+ // Set up the subscription to the activeAccount$ and cipherId$ observables
+ combineLatest([this.accountService.activeAccount$.pipe(getUserId), this._cipherId$])
+ .pipe(
+ tap(() => this.cleanUp()),
+ switchMap(([userId, cipherId]) => {
+ const cipher$ = this.cipherService.cipherViews$(userId).pipe(
+ map((ciphers) => ciphers?.find((c) => c.id === cipherId)),
+ filter((cipher) => !!cipher),
+ );
+ return combineLatest([of(userId), cipher$]);
+ }),
+ )
+ .subscribe(([userId, cipher]) => {
+ this.cipher = cipher;
+
+ void this.constructCipherDetails(userId);
+ });
}
ngOnDestroy() {
@@ -140,70 +178,6 @@ export class ViewComponent implements OnDestroy, OnInit {
this.cleanUp();
}
- async load() {
- this.cleanUp();
-
- // Grab individual cipher from `cipherViews$` for the most up-to-date information
- const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
- this.cipher = await firstValueFrom(
- this.cipherService.cipherViews$(activeUserId).pipe(
- map((ciphers) => ciphers?.find((c) => c.id === this.cipherId)),
- filter((cipher) => !!cipher),
- ),
- );
-
- this.canAccessPremium = await firstValueFrom(
- this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
- );
- this.showPremiumRequiredTotp =
- this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
- this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
- this.collectionId as CollectionId,
- ]);
- this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
-
- if (this.cipher.folderId) {
- this.folder = await (
- await firstValueFrom(this.folderService.folderViews$(activeUserId))
- ).find((f) => f.id == this.cipher.folderId);
- }
-
- const canGenerateTotp =
- this.cipher.type === CipherType.Login &&
- this.cipher.login.totp &&
- (this.cipher.organizationUseTotp || this.canAccessPremium);
-
- this.totpInfo$ = canGenerateTotp
- ? this.totpService.getCode$(this.cipher.login.totp).pipe(
- map((response) => {
- const epoch = Math.round(new Date().getTime() / 1000.0);
- const mod = epoch % response.period;
-
- // Format code
- const totpCodeFormatted =
- response.code.length > 4
- ? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
- : response.code;
-
- return {
- totpCode: response.code,
- totpCodeFormatted,
- totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
- totpSec: response.period - mod,
- totpLow: response.period - mod <= 7,
- } as TotpInfo;
- }),
- )
- : undefined;
-
- if (this.previousCipherId !== this.cipherId) {
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
- }
- this.previousCipherId = this.cipherId;
- }
-
async edit() {
this.onEditCipher.emit(this.cipher);
}
@@ -533,4 +507,61 @@ export class ViewComponent implements OnDestroy, OnInit {
this.showCardCode = false;
this.passwordReprompted = false;
}
+
+ /**
+ * When a cipher is viewed, construct all details for the view that are not directly
+ * available from the cipher object itself.
+ */
+ private async constructCipherDetails(userId: UserId) {
+ this.canAccessPremium = await firstValueFrom(
+ this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
+ );
+ this.showPremiumRequiredTotp =
+ this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
+ this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
+ this.collectionId as CollectionId,
+ ]);
+ this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
+
+ if (this.cipher.folderId) {
+ this.folder = await (
+ await firstValueFrom(this.folderService.folderViews$(userId))
+ ).find((f) => f.id == this.cipher.folderId);
+ }
+
+ const canGenerateTotp =
+ this.cipher.type === CipherType.Login &&
+ this.cipher.login.totp &&
+ (this.cipher.organizationUseTotp || this.canAccessPremium);
+
+ this.totpInfo$ = canGenerateTotp
+ ? this.totpService.getCode$(this.cipher.login.totp).pipe(
+ map((response) => {
+ const epoch = Math.round(new Date().getTime() / 1000.0);
+ const mod = epoch % response.period;
+
+ // Format code
+ const totpCodeFormatted =
+ response.code.length > 4
+ ? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
+ : response.code;
+
+ return {
+ totpCode: response.code,
+ totpCodeFormatted,
+ totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
+ totpSec: response.period - mod,
+ totpLow: response.period - mod <= 7,
+ } as TotpInfo;
+ }),
+ )
+ : undefined;
+
+ if (this.previousCipherId !== this.cipherId) {
+ // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
+ }
+ this.previousCipherId = this.cipherId;
+ }
}
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"
>
-
+
{
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,
useRiskInsights: false,
+ useAdminSponsoredFamilies: false,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts
index b81d06e6367..799d062aefa 100644
--- a/libs/common/src/admin-console/models/data/organization.data.ts
+++ b/libs/common/src/admin-console/models/data/organization.data.ts
@@ -60,6 +60,7 @@ export class OrganizationData {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
+ useAdminSponsoredFamilies: boolean;
constructor(
response?: ProfileOrganizationResponse,
@@ -122,6 +123,7 @@ export class OrganizationData {
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;
+ this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser;
diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts
index c5c5b53cce7..2e51c54b0ad 100644
--- a/libs/common/src/admin-console/models/domain/organization.ts
+++ b/libs/common/src/admin-console/models/domain/organization.ts
@@ -90,6 +90,7 @@ export class Organization {
*/
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
+ useAdminSponsoredFamilies: boolean;
constructor(obj?: OrganizationData) {
if (obj == null) {
@@ -148,6 +149,7 @@ export class Organization {
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;
+ this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
}
get canAccess() {
diff --git a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts
index 534afffd1bb..19e993487c2 100644
--- a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts
+++ b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts
@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
+ notes?: string;
}
diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts
index 5e37cfc4c5c..da97a1034b1 100644
--- a/libs/common/src/admin-console/models/response/profile-organization.response.ts
+++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts
@@ -55,6 +55,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
+ useAdminSponsoredFamilies: boolean;
constructor(response: any) {
super(response);
@@ -121,5 +122,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
);
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
+ this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
}
}
diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts
index 69309014fac..8024a120b0a 100644
--- a/libs/common/src/billing/abstractions/organization-billing.service.ts
+++ b/libs/common/src/billing/abstractions/organization-billing.service.ts
@@ -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;
+
+ /**
+ * 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/models/request/preview-organization-invoice.request.ts b/libs/common/src/billing/models/request/preview-organization-invoice.request.ts
index 40d8db03d3b..bfeecb4eb23 100644
--- a/libs/common/src/billing/models/request/preview-organization-invoice.request.ts
+++ b/libs/common/src/billing/models/request/preview-organization-invoice.request.ts
@@ -1,4 +1,4 @@
-import { PlanType } from "../../enums";
+import { PlanSponsorshipType, PlanType } from "../../enums";
export class PreviewOrganizationInvoiceRequest {
organizationId?: string;
@@ -21,6 +21,7 @@ export class PreviewOrganizationInvoiceRequest {
class PasswordManager {
plan: PlanType;
+ sponsoredPlan?: PlanSponsorshipType;
seats: number;
additionalStorage: number;
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