1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM-15506] Implement vNextOrganizationService (#12839)

* [PM-15506] Wire up vNextOrganizationService for libs/common and libs/angular (#12683)

* Wire up vNextOrganizationService in PolicyService

* Wire vNextOrganizationService in SyncService

* wire vNextOrganizationService for EventCollectionService

* wire vNextOrganizationService for KeyConnectorService

* wire up vNextOrganizationService for CipherAuthorizationService

* Wire up vNextOrganizationService in PolicyService

* Wire vNextOrganizationService in SyncService

* wire vNextOrganizationService for EventCollectionService

* wire vNextOrganizationService for KeyConnectorService

* wire up vNextOrganizationService for CipherAuthorizationService

* wire vNextOrganizationService for share.component

* wire vNextOrganizationService for collections.component

* wire vNextOrganizationServcie for add-account-credit-dialog

* wire vNextOrganizationService for vault-filter.service

* fix browser errors for vNextOrganizationService implementation in libs

* fix desktop errors for vNextOrganizationService implementation for libs

* fix linter errors

* fix CLI errors on vNextOrganizationServcie implementations for libs

* [PM-15506] Wire up vNextOrganizationService for web client (#12810)

PR to a feature branch, no need to review until this goes to main.

* implement vNextOrganization service for browser client (#12844)

PR to feature branch, no need for review yet.

* wire vNextOrganizationService for licence and some web router guards

* wire vNextOrganizationService in tests

* remove vNext notation for OrganizationService and related

* Merge branch 'main' into ac/pm-15506-vNextOrganizationService

* fix tsstrict error

* fix test, fix ts strict error
This commit is contained in:
Brandon Treston
2025-01-22 15:20:25 -05:00
committed by GitHub
parent ba4d762dc1
commit a949f793ed
163 changed files with 1972 additions and 1246 deletions

View File

@@ -6,11 +6,11 @@ import { OrganizationId, UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { DefaultvNextOrganizationService } from "./default-vnext-organization.service";
import { ORGANIZATIONS } from "./vnext-organization.state";
import { DefaultOrganizationService } from "./default-organization.service";
import { ORGANIZATIONS } from "./organization.state";
describe("OrganizationService", () => {
let organizationService: DefaultvNextOrganizationService;
let organizationService: DefaultOrganizationService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeStateProvider: FakeStateProvider;
@@ -86,7 +86,7 @@ describe("OrganizationService", () => {
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(fakeUserId));
organizationService = new DefaultvNextOrganizationService(fakeStateProvider);
organizationService = new DefaultOrganizationService(fakeStateProvider);
});
describe("canManageSponsorships", () => {

View File

@@ -4,11 +4,11 @@ import { map, Observable } from "rxjs";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { vNextInternalOrganizationServiceAbstraction } from "../../abstractions/organization/vnext.organization.service";
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { ORGANIZATIONS } from "./vnext-organization.state";
import { ORGANIZATIONS } from "./organization.state";
/**
* Filter out organizations from an observable that __do not__ offer a
@@ -41,9 +41,7 @@ function mapToBooleanHasAnyOrganizations() {
return map<Organization[], boolean>((orgs) => orgs.length > 0);
}
export class DefaultvNextOrganizationService
implements vNextInternalOrganizationServiceAbstraction
{
export class DefaultOrganizationService implements InternalOrganizationServiceAbstraction {
memberOrganizations$(userId: UserId): Observable<Organization[]> {
return this.organizations$(userId).pipe(mapToExcludeProviderOrganizations());
}

View File

@@ -1,324 +0,0 @@
import { firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { Utils } from "../../../platform/misc/utils";
import { OrganizationId, UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { OrganizationService, ORGANIZATIONS } from "./organization.service";
describe("OrganizationService", () => {
let organizationService: OrganizationService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeActiveUserState: FakeActiveUserState<Record<string, OrganizationData>>;
/**
* It is easier to read arrays than records in code, but we store a record
* in state. This helper methods lets us build organization arrays in tests
* and easily map them to records before storing them in state.
*/
function arrayToRecord(input: OrganizationData[]): Record<OrganizationId, OrganizationData> {
if (input == null) {
return undefined;
}
return Object.fromEntries(input?.map((i) => [i.id, i]));
}
/**
* There are a few assertions in this spec that check for array equality
* but want to ignore a specific index that _should_ be different. This
* function takes two arrays, and an index. It checks for equality of the
* arrays, but splices out the specified index from both arrays first.
*/
function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) {
// Clone the arrays to avoid modifying the reference values
const a = [...x];
const b = [...y];
delete a[indexToExclude];
delete b[indexToExclude];
expect(a).toEqual(b);
}
/**
* Builds a simple mock `OrganizationData[]` array that can be used in tests
* to populate state.
* @param count The number of organizations to populate the list with. The
* function returns undefined if this is less than 1. The default value is 1.
* @param suffix A string to append to data fields on each organization.
* This defaults to the index of the organization in the list.
* @returns an `OrganizationData[]` array that can be used to populate
* stateProvider.
*/
function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] {
if (count < 1) {
return undefined;
}
function buildMockOrganization(id: OrganizationId, name: string, identifier: string) {
const data = new OrganizationData({} as any, {} as any);
data.id = id;
data.name = name;
data.identifier = identifier;
return data;
}
const mockOrganizations = [];
for (let i = 0; i < count; i++) {
const s = suffix ? suffix + i.toString() : i.toString();
mockOrganizations.push(
buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s),
);
}
return mockOrganizations;
}
/**
* `OrganizationService` deals with multiple accounts at times. This helper
* function can be used to add a new non-active account to the test data.
* This function is **not** needed to handle creation of the first account,
* as that is handled by the `FakeAccountService` in `mockAccountServiceWith()`
* @returns The `UserId` of the newly created state account and the mock data
* created for them as an `Organization[]`.
*/
async function addNonActiveAccountToStateProvider(): Promise<[UserId, OrganizationData[]]> {
const nonActiveUserId = Utils.newGuid() as UserId;
const mockOrganizations = buildMockOrganizations(10);
const fakeNonActiveUserState = fakeStateProvider.singleUser.getFake(
nonActiveUserId,
ORGANIZATIONS,
);
fakeNonActiveUserState.nextState(arrayToRecord(mockOrganizations));
return [nonActiveUserId, mockOrganizations];
}
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(ORGANIZATIONS);
organizationService = new OrganizationService(fakeStateProvider);
});
it("getAll", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const orgs = await organizationService.getAll();
expect(orgs).toHaveLength(1);
const org = orgs[0];
expect(org).toEqual(new Organization(mockData[0]));
});
describe("canManageSponsorships", () => {
it("can because one is available", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipAvailable = true;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.canManageSponsorships$);
expect(result).toBe(true);
});
it("can because one is used", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = "Something";
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.canManageSponsorships$);
expect(result).toBe(true);
});
it("can not because one isn't available or taken", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.canManageSponsorships$);
expect(result).toBe(false);
});
});
describe("get", () => {
it("exists", async () => {
const mockData = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.get(mockData[0].id);
expect(result).toEqual(new Organization(mockData[0]));
});
it("does not exist", async () => {
const result = await organizationService.get("this-org-does-not-exist");
expect(result).toBe(undefined);
});
});
describe("organizations$", () => {
describe("null checking behavior", () => {
it("publishes an empty array if organizations in state = undefined", async () => {
const mockData: OrganizationData[] = undefined;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
it("publishes an empty array if organizations in state = null", async () => {
const mockData: OrganizationData[] = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
it("publishes an empty array if organizations in state = []", async () => {
const mockData: OrganizationData[] = [];
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
});
describe("parameter handling & returns", () => {
it("publishes all organizations for the active user by default", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData);
});
it("can be used to publish the organizations of a non active user if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserState");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(nonActiveUserMockOrganizations);
expect(result).not.toEqual(await firstValueFrom(organizationService.organizations$));
});
});
});
describe("upsert()", () => {
it("can create the organization list if necassary", async () => {
// Notice that no default state is provided in this test, so the list in
// `stateProvider` will be null when the `upsert` method is called.
const mockData = buildMockOrganizations();
await organizationService.upsert(mockData[0]);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData.map((x) => new Organization(x)));
});
it("updates an organization that already exists in state, defaulting to the active user", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: mockData[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization);
const result = await firstValueFrom(organizationService.organizations$);
expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate]));
expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id);
expectIsEqualExceptForIndex(
result,
mockData.map((x) => new Organization(x)),
indexToUpdate,
);
});
it("can also update an organization in state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: nonActiveUserMockOrganizations[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization, nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result[indexToUpdate]).not.toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]),
);
expect(result[indexToUpdate].id).toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]).id,
);
expectIsEqualExceptForIndex(
result,
nonActiveUserMockOrganizations.map((x) => new Organization(x)),
indexToUpdate,
);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
describe("replace()", () => {
it("replaces the entire organization list in state, defaulting to the active user", async () => {
const originalData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(originalData));
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(newData);
expect(result).not.toEqual(originalData);
});
// This is more or less a test for logouts
it("can replace state with null", async () => {
const originalData = buildMockOrganizations(2);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await organizationService.replace(null);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
expect(result).not.toEqual(originalData);
});
it("can also replace state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, originalOrganizations] = await addNonActiveAccountToStateProvider();
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData), nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(newData);
expect(result).not.toEqual(originalOrganizations);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
});

View File

@@ -1,160 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { map, Observable, firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { ORGANIZATIONS_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
/**
* The `KeyDefinition` for accessing organization lists in application state.
* @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData`
* has some properties that contain functions. This should probably get
* cleaned up.
*/
export const ORGANIZATIONS = UserKeyDefinition.record<OrganizationData>(
ORGANIZATIONS_DISK,
"organizations",
{
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
clearOn: ["logout"],
},
);
/**
* Filter out organizations from an observable that __do not__ offer a
* families-for-enterprise sponsorship to members.
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() {
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.canManageSponsorships));
}
/**
* Filter out organizations from an observable that the organization user
* __is not__ a direct member of. This will exclude organizations only
* accessible as a provider.
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeProviderOrganizations() {
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.isMember));
}
/**
* Map an observable stream of organizations down to a boolean indicating
* if any organizations exist (`orgs.length > 0`).
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToBooleanHasAnyOrganizations() {
return map<Organization[], boolean>((orgs) => orgs.length > 0);
}
/**
* Map an observable stream of organizations down to a single organization.
* @param `organizationId` The ID of the organization you'd like to subscribe to
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToSingleOrganization(organizationId: string) {
return map<Organization[], Organization>((orgs) => orgs?.find((o) => o.id === organizationId));
}
export class OrganizationService implements InternalOrganizationServiceAbstraction {
organizations$: Observable<Organization[]> = this.getOrganizationsFromState$();
memberOrganizations$: Observable<Organization[]> = this.organizations$.pipe(
mapToExcludeProviderOrganizations(),
);
constructor(private stateProvider: StateProvider) {}
get$(id: string): Observable<Organization | undefined> {
return this.organizations$.pipe(mapToSingleOrganization(id));
}
getAll$(userId?: UserId): Observable<Organization[]> {
return this.getOrganizationsFromState$(userId);
}
async getAll(userId?: string): Promise<Organization[]> {
return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId));
}
canManageSponsorships$ = this.organizations$.pipe(
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
mapToBooleanHasAnyOrganizations(),
);
familySponsorshipAvailable$ = this.organizations$.pipe(
map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)),
);
async hasOrganizations(): Promise<boolean> {
return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations()));
}
async upsert(organization: OrganizationData, userId?: UserId): Promise<void> {
await this.stateFor(userId).update((existingOrganizations) => {
const organizations = existingOrganizations ?? {};
organizations[organization.id] = organization;
return organizations;
});
}
async get(id: string): Promise<Organization> {
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
/**
* @deprecated For the CLI only
* @param id id of the organization
*/
async getFromState(id: string): Promise<Organization> {
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
async replace(organizations: { [id: string]: OrganizationData }, userId?: UserId): Promise<void> {
await this.stateFor(userId).update(() => organizations);
}
// Ideally this method would be renamed to organizations$() and the
// $organizations observable as it stands would be removed. This will
// require updates to callers, and so this method exists as a temporary
// workaround until we have time & a plan to update callers.
//
// It can be thought of as "organizations$ but with a userId option".
private getOrganizationsFromState$(userId?: UserId): Observable<Organization[] | undefined> {
return this.stateFor(userId).state$.pipe(this.mapOrganizationRecordToArray());
}
/**
* Accepts a record of `OrganizationData`, which is how we store the
* organization list as a JSON object on disk, to an array of
* `Organization`, which is how the data is published to callers of the
* service.
* @returns a function that can be used to pipe organization data from
* stored state to an exposed object easily consumable by others.
*/
private mapOrganizationRecordToArray() {
return map<Record<string, OrganizationData>, Organization[]>((orgs) =>
Object.values(orgs ?? {})?.map((o) => new Organization(o)),
);
}
/**
* Fetches the organization list from on disk state for the specified user.
* @param userId the user ID to fetch the organization list for. Defaults to
* the currently active user.
* @returns an observable of organization state as it is stored on disk.
*/
private stateFor(userId?: UserId) {
return userId
? this.stateProvider.getUser(userId, ORGANIZATIONS)
: this.stateProvider.getActive(ORGANIZATIONS);
}
}

View File

@@ -3,7 +3,6 @@ import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import {
OrganizationUserStatusType,
OrganizationUserType,
@@ -18,6 +17,7 @@ import { Policy } from "../../../admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
describe("PolicyService", () => {
const userId = "userId" as UserId;
@@ -56,9 +56,7 @@ describe("PolicyService", () => {
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
]);
organizationService.organizations$ = organizations$;
organizationService.getAll$.mockReturnValue(organizations$);
organizationService.organizations$.mockReturnValue(organizations$);
policyService = new PolicyService(stateProvider, organizationService);
});
@@ -196,7 +194,7 @@ describe("PolicyService", () => {
describe("getResetPasswordPolicyOptions", () => {
it("default", async () => {
const result = policyService.getResetPasswordPolicyOptions(null, null);
const result = policyService.getResetPasswordPolicyOptions([], "");
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
});

View File

@@ -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 } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
import { PolicyId, UserId } from "../../../types/guid";
@@ -39,7 +39,11 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
map((policies) => policies.filter((p) => p.type === policyType)),
);
return combineLatest([filteredPolicies$, this.organizationService.organizations$]).pipe(
const organizations$ = this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.organizationService.organizations$(userId)),
);
return combineLatest([filteredPolicies$, organizations$]).pipe(
map(
([policies, organizations]) =>
this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null,
@@ -53,7 +57,7 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
map((policies) => policies.filter((p) => p.type === policyType)),
);
return combineLatest([filteredPolicies$, this.organizationService.getAll$(userId)]).pipe(
return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe(
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
);
}