mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
[pm-14025] Remove usage of ActiveUserState from organization.service (#11799)
* WIP refactor organizationService * rename file, add tests * fix tests, remove promisies from servcie * rename tests, classes, and files. Remove unneeded code * refactor organization service function params to expect a UserId * fix test --------- Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
import { map, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||||
|
import { Utils } from "../../../platform/misc/utils";
|
||||||
|
import { UserId } from "../../../types/guid";
|
||||||
|
import { OrganizationData } from "../../models/data/organization.data";
|
||||||
|
import { Organization } from "../../models/domain/organization";
|
||||||
|
|
||||||
|
export function canAccessVaultTab(org: Organization): boolean {
|
||||||
|
return org.canViewAllCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessSettingsTab(org: Organization): boolean {
|
||||||
|
return (
|
||||||
|
org.isOwner ||
|
||||||
|
org.canManagePolicies ||
|
||||||
|
org.canManageSso ||
|
||||||
|
org.canManageScim ||
|
||||||
|
org.canAccessImportExport ||
|
||||||
|
org.canManageDeviceApprovals
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessMembersTab(org: Organization): boolean {
|
||||||
|
return org.canManageUsers || org.canManageUsersPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessGroupsTab(org: Organization): boolean {
|
||||||
|
return org.canManageGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessReportingTab(org: Organization): boolean {
|
||||||
|
return org.canAccessReports || org.canAccessEventLogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessBillingTab(org: Organization): boolean {
|
||||||
|
return org.isOwner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessOrgAdmin(org: Organization): boolean {
|
||||||
|
// Admin console can only be accessed by Owners for disabled organizations
|
||||||
|
if (!org.enabled && !org.isOwner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
canAccessMembersTab(org) ||
|
||||||
|
canAccessGroupsTab(org) ||
|
||||||
|
canAccessReportingTab(org) ||
|
||||||
|
canAccessBillingTab(org) ||
|
||||||
|
canAccessSettingsTab(org) ||
|
||||||
|
canAccessVaultTab(org)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationById(id: string) {
|
||||||
|
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessAdmin(i18nService: I18nService) {
|
||||||
|
return map<Organization[], Organization[]>((orgs) =>
|
||||||
|
orgs.filter(canAccessOrgAdmin).sort(Utils.getSortFunction(i18nService, "name")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessImport(i18nService: I18nService) {
|
||||||
|
return map<Organization[], Organization[]>((orgs) =>
|
||||||
|
orgs
|
||||||
|
.filter((org) => org.canAccessImportExport || org.canCreateNewCollections)
|
||||||
|
.sort(Utils.getSortFunction(i18nService, "name")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes an observable stream of organizations. This service is meant to
|
||||||
|
* be used widely across Bitwarden as the primary way of fetching organizations.
|
||||||
|
* Risky operations like updates are isolated to the
|
||||||
|
* internal extension `InternalOrganizationServiceAbstraction`.
|
||||||
|
*/
|
||||||
|
export abstract class vNextOrganizationService {
|
||||||
|
/**
|
||||||
|
* Publishes state for all organizations under the specified user.
|
||||||
|
* @returns An observable list of organizations
|
||||||
|
*/
|
||||||
|
organizations$: (userId: UserId) => Observable<Organization[]>;
|
||||||
|
|
||||||
|
// @todo Clean these up. Continuing to expand them is not recommended.
|
||||||
|
// @see https://bitwarden.atlassian.net/browse/AC-2252
|
||||||
|
memberOrganizations$: (userId: UserId) => Observable<Organization[]>;
|
||||||
|
/**
|
||||||
|
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
|
||||||
|
*/
|
||||||
|
canManageSponsorships$: (userId: UserId) => Observable<boolean>;
|
||||||
|
/**
|
||||||
|
* Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available.
|
||||||
|
*/
|
||||||
|
familySponsorshipAvailable$: (userId: UserId) => Observable<boolean>;
|
||||||
|
hasOrganizations: (userId: UserId) => Observable<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Big scary buttons that **update** organization state. These should only be
|
||||||
|
* called from within admin-console scoped code. Extends the base
|
||||||
|
* `OrganizationService` for easy access to `get` calls.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export abstract class vNextInternalOrganizationServiceAbstraction extends vNextOrganizationService {
|
||||||
|
/**
|
||||||
|
* Replaces state for the provided organization, or creates it if not found.
|
||||||
|
* @param organization The organization state being saved.
|
||||||
|
* @param userId The userId to replace state for.
|
||||||
|
*/
|
||||||
|
upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces state for the entire registered organization list for the specified user.
|
||||||
|
* You probably don't want this unless you're calling from a full sync
|
||||||
|
* operation or a logout. See `upsert` for creating & updating a single
|
||||||
|
* organization in the state.
|
||||||
|
* @param organizations A complete list of all organization state for the provided
|
||||||
|
* user.
|
||||||
|
* @param userId The userId to replace state for.
|
||||||
|
*/
|
||||||
|
replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||||
|
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 { DefaultvNextOrganizationService } from "./default-vnext-organization.service";
|
||||||
|
import { ORGANIZATIONS } from "./vnext-organization.state";
|
||||||
|
|
||||||
|
describe("OrganizationService", () => {
|
||||||
|
let organizationService: DefaultvNextOrganizationService;
|
||||||
|
|
||||||
|
const fakeUserId = Utils.newGuid() as UserId;
|
||||||
|
let fakeStateProvider: FakeStateProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setOrganizationsState = (organizationData: OrganizationData[] | null) =>
|
||||||
|
fakeStateProvider.setUserState(
|
||||||
|
ORGANIZATIONS,
|
||||||
|
organizationData == null ? null : arrayToRecord(organizationData),
|
||||||
|
fakeUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(fakeUserId));
|
||||||
|
organizationService = new DefaultvNextOrganizationService(fakeStateProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canManageSponsorships", () => {
|
||||||
|
it("can because one is available", async () => {
|
||||||
|
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||||
|
mockData[0].familySponsorshipAvailable = true;
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can because one is used", async () => {
|
||||||
|
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||||
|
mockData[0].familySponsorshipFriendlyName = "Something";
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can not because one isn't available or taken", async () => {
|
||||||
|
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||||
|
mockData[0].familySponsorshipFriendlyName = null;
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId));
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("organizations$", () => {
|
||||||
|
describe("null checking behavior", () => {
|
||||||
|
it("publishes an empty array if organizations in state = undefined", async () => {
|
||||||
|
const mockData: OrganizationData[] = undefined;
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes an empty array if organizations in state = null", async () => {
|
||||||
|
const mockData: OrganizationData[] = null;
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes an empty array if organizations in state = []", async () => {
|
||||||
|
const mockData: OrganizationData[] = [];
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns state for a user", async () => {
|
||||||
|
const mockData = buildMockOrganizations(10);
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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], fakeUserId);
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
expect(result).toEqual(mockData.map((x) => new Organization(x)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an organization that already exists in state", async () => {
|
||||||
|
const mockData = buildMockOrganizations(10);
|
||||||
|
await setOrganizationsState(mockData);
|
||||||
|
const indexToUpdate = 5;
|
||||||
|
const anUpdatedOrganization = {
|
||||||
|
...buildMockOrganizations(1, "UPDATED").pop(),
|
||||||
|
id: mockData[indexToUpdate].id,
|
||||||
|
};
|
||||||
|
await organizationService.upsert(anUpdatedOrganization, fakeUserId);
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replace()", () => {
|
||||||
|
it("replaces the entire organization list in state", async () => {
|
||||||
|
const originalData = buildMockOrganizations(10);
|
||||||
|
await setOrganizationsState(originalData);
|
||||||
|
|
||||||
|
const newData = buildMockOrganizations(10, "newData");
|
||||||
|
await organizationService.replace(arrayToRecord(newData), fakeUserId);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
|
||||||
|
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);
|
||||||
|
await setOrganizationsState(originalData);
|
||||||
|
await organizationService.replace(null, fakeUserId);
|
||||||
|
const result = await firstValueFrom(organizationService.organizations$(fakeUserId));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(result).not.toEqual(originalData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { map, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { StateProvider } from "../../../platform/state";
|
||||||
|
import { UserId } from "../../../types/guid";
|
||||||
|
import { vNextInternalOrganizationServiceAbstraction } from "../../abstractions/organization/vnext.organization.service";
|
||||||
|
import { OrganizationData } from "../../models/data/organization.data";
|
||||||
|
import { Organization } from "../../models/domain/organization";
|
||||||
|
|
||||||
|
import { ORGANIZATIONS } from "./vnext-organization.state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DefaultvNextOrganizationService
|
||||||
|
implements vNextInternalOrganizationServiceAbstraction
|
||||||
|
{
|
||||||
|
memberOrganizations$(userId: UserId): Observable<Organization[]> {
|
||||||
|
return this.organizations$(userId).pipe(mapToExcludeProviderOrganizations());
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private stateProvider: StateProvider) {}
|
||||||
|
|
||||||
|
canManageSponsorships$(userId: UserId) {
|
||||||
|
return this.organizations$(userId).pipe(
|
||||||
|
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
|
||||||
|
mapToBooleanHasAnyOrganizations(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
familySponsorshipAvailable$(userId: UserId) {
|
||||||
|
return this.organizations$(userId).pipe(
|
||||||
|
map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOrganizations(userId: UserId): Observable<boolean> {
|
||||||
|
return this.organizations$(userId).pipe(mapToBooleanHasAnyOrganizations());
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(organization: OrganizationData, userId: UserId): Promise<void> {
|
||||||
|
await this.organizationState(userId).update((existingOrganizations) => {
|
||||||
|
const organizations = existingOrganizations ?? {};
|
||||||
|
organizations[organization.id] = organization;
|
||||||
|
return organizations;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replace(organizations: { [id: string]: OrganizationData }, userId: UserId): Promise<void> {
|
||||||
|
await this.organizationState(userId).update(() => organizations);
|
||||||
|
}
|
||||||
|
|
||||||
|
organizations$(userId: UserId): Observable<Organization[] | undefined> {
|
||||||
|
return this.organizationState(userId).state$.pipe(this.mapOrganizationRecordToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private organizationState(userId: UserId) {
|
||||||
|
return this.stateProvider.getUser(userId, ORGANIZATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { ORGANIZATIONS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
|
||||||
|
import { OrganizationData } from "../../models/data/organization.data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"],
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user