1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-24146] Remove stateProvider.activeUserId from ProviderService (#16258)

* Refactor provider service calls to include userId parameter

- Updated multiple components and services to pass userId when fetching provider data.
- Adjusted the ProviderService interface to require userId for get, get$, and getAll methods.
- Ensured consistent handling of userId across various components, enhancing data retrieval based on active user context.

* Remove deprecated type safety comments and use the getById utility for fetching providers.

* Update ProviderService methods to return undefined for non-existent providers

- Modified the return types of get$ and get methods in ProviderService to allow for undefined values, enhancing type safety.
- Adjusted the providers$ method to return only defined Provider arrays, ensuring consistent handling of provider data.

* Enhance provider permissions guard tests to include userId parameter

- Updated test cases in provider-permissions.guard.spec.ts to pass userId when calling ProviderService methods.
- Mocked AccountService to provide active account details for improved test coverage.
- Ensured consistent handling of userId across all relevant test scenarios.

* remove promise based api's from provider service, continue refactor

* cleanup observable logic

* cleanup

---------

Co-authored-by: Brandon <btreston@bitwarden.com>
This commit is contained in:
Rui Tomé
2025-09-22 16:06:28 +01:00
committed by GitHub
parent dbec02cf8d
commit b455cb5986
24 changed files with 342 additions and 281 deletions

View File

@@ -121,8 +121,13 @@ export class OrganizationLayoutComponent implements OnInit {
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)), switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
); );
const provider$ = this.organization$.pipe( const provider$ = combineLatest([
switchMap((organization) => this.providerService.get$(organization.providerId)), this.organization$,
this.accountService.activeAccount$.pipe(getUserId),
]).pipe(
switchMap(([organization, userId]) =>
this.providerService.get$(organization.providerId, userId),
),
); );
this.organizationIsUnmanaged$ = combineLatest([this.organization$, provider$]).pipe( this.organizationIsUnmanaged$ = combineLatest([this.organization$, provider$]).pipe(

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore // @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { concatMap, firstValueFrom, lastValueFrom, takeUntil } from "rxjs"; import { concatMap, filter, firstValueFrom, lastValueFrom, map, switchMap, takeUntil } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
@@ -136,22 +136,24 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe
if (this.organization.providerId != null) { if (this.organization.providerId != null) {
try { try {
const provider = await this.providerService.get(this.organization.providerId); await firstValueFrom(
if ( this.accountService.activeAccount$.pipe(
provider != null && getUserId,
(await this.providerService.get(this.organization.providerId)).canManageUsers switchMap((userId) => this.providerService.get$(this.organization.providerId, userId)),
) { map((provider) => provider != null && provider.canManageUsers),
const providerUsersResponse = await this.apiService.getProviderUsers( filter((result) => result),
this.organization.providerId, switchMap(() => this.apiService.getProviderUsers(this.organization.id)),
); map((providerUsersResponse) =>
providerUsersResponse.data.forEach((u) => { providerUsersResponse.data.forEach((u) => {
const name = this.userNamePipe.transform(u); const name = this.userNamePipe.transform(u);
this.orgUsersUserIdMap.set(u.userId, { this.orgUsersUserIdMap.set(u.userId, {
name: `${name} (${this.organization.providerName})`, name: `${name} (${this.organization.providerName})`,
email: u.email, email: u.email,
}); });
}); }),
} ),
),
);
} catch (e) { } catch (e) {
this.logService.warning(e); this.logService.warning(e);
} }

View File

@@ -35,7 +35,7 @@ export const organizationIsUnmanaged: CanActivateFn = async (route: ActivatedRou
return true; return true;
} }
const provider = await providerService.get(organization.providerId); const provider = await firstValueFrom(providerService.get$(organization.providerId, userId));
if (!provider) { if (!provider) {
return true; return true;

View File

@@ -1,7 +1,7 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core"; import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; import { BehaviorSubject, Observable, of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -47,8 +47,8 @@ class MockOrganizationService implements Partial<OrganizationService> {
class MockProviderService implements Partial<ProviderService> { class MockProviderService implements Partial<ProviderService> {
private static _providers = new BehaviorSubject<Provider[]>([]); private static _providers = new BehaviorSubject<Provider[]>([]);
async getAll() { providers$() {
return await firstValueFrom(MockProviderService._providers); return MockProviderService._providers.asObservable();
} }
@Input() @Input()

View File

@@ -1,7 +1,7 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core"; import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; import { BehaviorSubject, Observable, of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -47,8 +47,8 @@ class MockOrganizationService implements Partial<OrganizationService> {
class MockProviderService implements Partial<ProviderService> { class MockProviderService implements Partial<ProviderService> {
private static _providers = new BehaviorSubject<Provider[]>([]); private static _providers = new BehaviorSubject<Provider[]>([]);
async getAll() { providers$() {
return await firstValueFrom(MockProviderService._providers); return MockProviderService._providers.asObservable();
} }
@Input() @Input()

View File

@@ -52,7 +52,7 @@ describe("ProductSwitcherService", () => {
router.url = "/"; router.url = "/";
router.events = of({}); router.events = of({});
organizationService.organizations$.mockReturnValue(of([{}] as Organization[])); organizationService.organizations$.mockReturnValue(of([{}] as Organization[]));
providerService.getAll.mockResolvedValue([] as Provider[]); providerService.providers$.mockReturnValue(of([]) as Observable<Provider[]>);
platformUtilsService.isSelfHost.mockReturnValue(false); platformUtilsService.isSelfHost.mockReturnValue(false);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -212,7 +212,7 @@ describe("ProductSwitcherService", () => {
}); });
it("is included when there are providers", async () => { it("is included when there are providers", async () => {
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]); providerService.providers$.mockReturnValue(of([{ id: "67899" }]) as Observable<Provider[]>);
initiateService(); initiateService();
@@ -263,7 +263,7 @@ describe("ProductSwitcherService", () => {
}); });
it("marks Provider Portal as active", async () => { it("marks Provider Portal as active", async () => {
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]); providerService.providers$.mockReturnValue(of([{ id: "67899" }]) as Observable<Provider[]>);
router.url = "/providers/"; router.url = "/providers/";
initiateService(); initiateService();

View File

@@ -2,17 +2,7 @@
// @ts-strict-ignore // @ts-strict-ignore
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router"; import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router";
import { import { combineLatest, filter, map, Observable, ReplaySubject, startWith, switchMap } from "rxjs";
combineLatest,
concatMap,
filter,
firstValueFrom,
map,
Observable,
ReplaySubject,
startWith,
switchMap,
} from "rxjs";
import { import {
canAccessOrgAdmin, canAccessOrgAdmin,
@@ -22,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { PolicyType, ProviderType } from "@bitwarden/common/admin-console/enums"; import { PolicyType, ProviderType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -117,148 +108,159 @@ export class ProductSwitcherService {
switchMap((id) => this.organizationService.organizations$(id)), switchMap((id) => this.organizationService.organizations$(id)),
); );
providers$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((id) => this.providerService.providers$(id)),
);
userHasSingleOrgPolicy$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
);
products$: Observable<{ products$: Observable<{
bento: ProductSwitcherItem[]; bento: ProductSwitcherItem[];
other: ProductSwitcherItem[]; other: ProductSwitcherItem[];
}> = combineLatest([this.organizations$, this.route.paramMap, this.triggerProductUpdate$]).pipe( }> = combineLatest([
map(([orgs, ...rest]): [Organization[], ParamMap, void] => { this.organizations$,
return [ this.providers$,
this.userHasSingleOrgPolicy$,
this.route.paramMap,
this.triggerProductUpdate$,
]).pipe(
map(
([orgs, providers, userHasSingleOrgPolicy, paramMap]: [
Organization[],
Provider[],
boolean,
ParamMap,
void,
]) => {
// Sort orgs by name to match the order within the sidebar // Sort orgs by name to match the order within the sidebar
orgs.sort((a, b) => a.name.localeCompare(b.name)), orgs.sort((a, b) => a.name.localeCompare(b.name));
...rest,
];
}),
concatMap(async ([orgs, paramMap]) => {
let routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
let organizationIdViaPath: string | null = null; let routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
if (["/sm/", "/organizations/"].some((path) => this.router.url.includes(path))) { let organizationIdViaPath: string | null = null;
// Grab the organization ID from the URL
organizationIdViaPath = this.router.url.split("/")[2] ?? null;
}
// When the user is already viewing an organization within an application use it as the active route org if (["/sm/", "/organizations/"].some((path) => this.router.url.includes(path))) {
if (organizationIdViaPath && !routeOrg) { // Grab the organization ID from the URL
routeOrg = orgs.find((o) => o.id === organizationIdViaPath); organizationIdViaPath = this.router.url.split("/")[2] ?? null;
}
// If the active route org doesn't have access to SM, find the first org that does.
const smOrg =
routeOrg?.canAccessSecretsManager && routeOrg?.enabled == true
? routeOrg
: orgs.find((o) => o.canAccessSecretsManager && o.enabled == true);
// If the active route org doesn't have access to AC, find the first org that does.
const acOrg =
routeOrg != null && canAccessOrgAdmin(routeOrg)
? routeOrg
: orgs.find((o) => canAccessOrgAdmin(o));
// TODO: This should be migrated to an Observable provided by the provider service and moved to the combineLatest above. See AC-2092.
const providers = await this.providerService.getAll();
const providerPortalName =
providers[0]?.providerType === ProviderType.BusinessUnit
? "Business Unit Portal"
: "Provider Portal";
const orgsMarketingRoute = this.platformUtilsService.isSelfHost()
? {
route: "https://bitwarden.com/products/business/",
external: true,
}
: {
route: "/create-organization",
external: false,
};
const products = {
pm: {
name: "Password Manager",
icon: "bwi-lock",
appRoute: "/vault",
marketingRoute: {
route: "https://bitwarden.com/products/personal/",
external: true,
},
isActive:
!this.router.url.includes("/sm/") &&
!this.router.url.includes("/organizations/") &&
!this.router.url.includes("/providers/"),
},
sm: {
name: "Secrets Manager",
icon: "bwi-cli",
appRoute: ["/sm", smOrg?.id],
marketingRoute: {
route: "/sm-landing",
external: false,
},
isActive: this.router.url.includes("/sm/"),
otherProductOverrides: {
supportingText: this.i18nService.t("secureYourInfrastructure"),
},
},
ac: {
name: "Admin Console",
icon: "bwi-business",
appRoute: ["/organizations", acOrg?.id],
marketingRoute: {
route: "https://bitwarden.com/products/business/",
external: true,
},
isActive: this.router.url.includes("/organizations/"),
},
provider: {
name: providerPortalName,
icon: "bwi-provider",
appRoute: ["/providers", providers[0]?.id],
isActive: this.router.url.includes("/providers/"),
},
orgs: {
name: "Organizations",
icon: "bwi-business",
marketingRoute: orgsMarketingRoute,
otherProductOverrides: {
name: "Share your passwords",
supportingText: this.i18nService.t("protectYourFamilyOrBusiness"),
},
},
} satisfies Record<string, ProductSwitcherItem>;
const bento: ProductSwitcherItem[] = [products.pm];
const other: ProductSwitcherItem[] = [];
if (smOrg) {
bento.push(products.sm);
} else {
other.push(products.sm);
}
if (acOrg) {
bento.push(products.ac);
} else {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const userHasSingleOrgPolicy = await firstValueFrom(
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, activeUserId),
);
if (!userHasSingleOrgPolicy) {
other.push(products.orgs);
} }
}
if (providers.length > 0) { // When the user is already viewing an organization within an application use it as the active route org
bento.push(products.provider); if (organizationIdViaPath && !routeOrg) {
} routeOrg = orgs.find((o) => o.id === organizationIdViaPath);
}
return { // If the active route org doesn't have access to SM, find the first org that does.
bento, const smOrg =
other, routeOrg?.canAccessSecretsManager && routeOrg?.enabled == true
}; ? routeOrg
}), : orgs.find((o) => o.canAccessSecretsManager && o.enabled == true);
// If the active route org doesn't have access to AC, find the first org that does.
const acOrg =
routeOrg != null && canAccessOrgAdmin(routeOrg)
? routeOrg
: orgs.find((o) => canAccessOrgAdmin(o));
const providerPortalName =
providers[0]?.providerType === ProviderType.BusinessUnit
? "Business Unit Portal"
: "Provider Portal";
const orgsMarketingRoute = this.platformUtilsService.isSelfHost()
? {
route: "https://bitwarden.com/products/business/",
external: true,
}
: {
route: "/create-organization",
external: false,
};
const products = {
pm: {
name: "Password Manager",
icon: "bwi-lock",
appRoute: "/vault",
marketingRoute: {
route: "https://bitwarden.com/products/personal/",
external: true,
},
isActive:
!this.router.url.includes("/sm/") &&
!this.router.url.includes("/organizations/") &&
!this.router.url.includes("/providers/"),
},
sm: {
name: "Secrets Manager",
icon: "bwi-cli",
appRoute: ["/sm", smOrg?.id],
marketingRoute: {
route: "/sm-landing",
external: false,
},
isActive: this.router.url.includes("/sm/"),
otherProductOverrides: {
supportingText: this.i18nService.t("secureYourInfrastructure"),
},
},
ac: {
name: "Admin Console",
icon: "bwi-business",
appRoute: ["/organizations", acOrg?.id],
marketingRoute: {
route: "https://bitwarden.com/products/business/",
external: true,
},
isActive: this.router.url.includes("/organizations/"),
},
provider: {
name: providerPortalName,
icon: "bwi-provider",
appRoute: ["/providers", providers[0]?.id],
isActive: this.router.url.includes("/providers/"),
},
orgs: {
name: "Organizations",
icon: "bwi-business",
marketingRoute: orgsMarketingRoute,
otherProductOverrides: {
name: "Share your passwords",
supportingText: this.i18nService.t("protectYourFamilyOrBusiness"),
},
},
} satisfies Record<string, ProductSwitcherItem>;
const bento: ProductSwitcherItem[] = [products.pm];
const other: ProductSwitcherItem[] = [];
if (smOrg) {
bento.push(products.sm);
} else {
other.push(products.sm);
}
if (acOrg) {
bento.push(products.ac);
} else {
if (!userHasSingleOrgPolicy) {
other.push(products.orgs);
}
}
if (providers.length > 0) {
bento.push(products.provider);
}
return {
bento,
other,
};
},
),
); );
/** Poll the `syncService` until a sync is completed */ /** Poll the `syncService` until a sync is completed */

View File

@@ -11,7 +11,14 @@
{{ o.name }} {{ o.name }}
</td> </td>
<td bitCell> <td bitCell>
<button type="button" bitButton [bitAction]="add(o)" class="tw-float-right">Add</button> <button
type="button"
bitButton
[bitAction]="add(o, provider$ | async)"
class="tw-float-right"
>
Add
</button>
</td> </td>
</tr> </tr>
</ng-template> </ng-template>

View File

@@ -1,10 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, Inject, OnInit } from "@angular/core"; import { Component, Inject, OnInit } from "@angular/core";
import { Observable, switchMap } from "rxjs";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -22,7 +25,7 @@ interface AddOrganizationDialogData {
standalone: false, standalone: false,
}) })
export class AddOrganizationComponent implements OnInit { export class AddOrganizationComponent implements OnInit {
protected provider: Provider; protected provider$: Observable<Provider>;
protected loading = true; protected loading = true;
constructor( constructor(
@@ -35,6 +38,7 @@ export class AddOrganizationComponent implements OnInit {
private validationService: ValidationService, private validationService: ValidationService,
private dialogService: DialogService, private dialogService: DialogService,
private toastService: ToastService, private toastService: ToastService,
private accountService: AccountService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -46,18 +50,21 @@ export class AddOrganizationComponent implements OnInit {
return; return;
} }
this.provider = await this.providerService.get(this.data.providerId); this.provider$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.providerService.get$(this.data.providerId, userId)),
);
this.loading = false; this.loading = false;
} }
add(organization: Organization) { add(organization: Organization, provider: Provider) {
return async () => { return async () => {
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: organization.name, title: organization.name,
content: { content: {
key: "addOrganizationConfirmation", key: "addOrganizationConfirmation",
placeholders: [organization.name, this.provider.name], placeholders: [organization.name, provider.name],
}, },
type: "warning", type: "warning",
}); });

View File

@@ -3,7 +3,7 @@ import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom, from, map, Observable, switchMap } from "rxjs"; import { combineLatest, firstValueFrom, from, map, Observable, switchMap } from "rxjs";
import { debounceTime, first } from "rxjs/operators"; import { debounceTime, first } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -65,9 +65,10 @@ export class ClientsComponent {
this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ?? this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ??
new Observable(); new Observable();
protected provider$ = this.providerId$.pipe( protected provider$ = combineLatest([
switchMap((providerId) => this.providerService.get$(providerId)), this.providerId$,
); this.accountService.activeAccount$.pipe(getUserId),
]).pipe(switchMap(([providerId, userId]) => this.providerService.get$(providerId, userId)));
protected isAdminOrServiceUser$ = this.provider$.pipe( protected isAdminOrServiceUser$ = this.provider$.pipe(
map( map(

View File

@@ -3,12 +3,16 @@
import { TestBed } from "@angular/core/testing"; import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router"; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { providerPermissionsGuard } from "./provider-permissions.guard"; import { providerPermissionsGuard } from "./provider-permissions.guard";
@@ -25,11 +29,23 @@ const providerFactory = (props: Partial<Provider> = {}) =>
describe("Provider Permissions Guard", () => { describe("Provider Permissions Guard", () => {
let providerService: MockProxy<ProviderService>; let providerService: MockProxy<ProviderService>;
let accountService: MockProxy<AccountService>;
let route: MockProxy<ActivatedRouteSnapshot>; let route: MockProxy<ActivatedRouteSnapshot>;
let state: MockProxy<RouterStateSnapshot>; let state: MockProxy<RouterStateSnapshot>;
const mockUserId = newGuid() as UserId;
beforeEach(() => { beforeEach(() => {
providerService = mock<ProviderService>(); providerService = mock<ProviderService>();
accountService = mock<AccountService>();
accountService.activeAccount$ = of({
id: mockUserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
});
route = mock<ActivatedRouteSnapshot>({ route = mock<ActivatedRouteSnapshot>({
params: { params: {
providerId: providerFactory().id, providerId: providerFactory().id,
@@ -44,12 +60,15 @@ describe("Provider Permissions Guard", () => {
{ provide: ToastService, useValue: mock<ToastService>() }, { provide: ToastService, useValue: mock<ToastService>() },
{ provide: I18nService, useValue: mock<I18nService>() }, { provide: I18nService, useValue: mock<I18nService>() },
{ provide: Router, useValue: mock<Router>() }, { provide: Router, useValue: mock<Router>() },
{ provide: AccountService, useValue: accountService },
], ],
}); });
}); });
it("blocks navigation if provider does not exist", async () => { it("blocks navigation if provider does not exist", async () => {
providerService.get.mockResolvedValue(null); providerService.get$
.calledWith(providerFactory().id, mockUserId)
.mockReturnValue(of(undefined));
const actual = await TestBed.runInInjectionContext( const actual = await TestBed.runInInjectionContext(
async () => await providerPermissionsGuard()(route, state), async () => await providerPermissionsGuard()(route, state),
@@ -60,7 +79,7 @@ describe("Provider Permissions Guard", () => {
it("permits navigation if no permissions are specified", async () => { it("permits navigation if no permissions are specified", async () => {
const provider = providerFactory(); const provider = providerFactory();
providerService.get.calledWith(provider.id).mockResolvedValue(provider); providerService.get$.calledWith(provider.id, mockUserId).mockReturnValue(of(provider));
const actual = await TestBed.runInInjectionContext( const actual = await TestBed.runInInjectionContext(
async () => await providerPermissionsGuard()(route, state), async () => await providerPermissionsGuard()(route, state),
@@ -74,7 +93,7 @@ describe("Provider Permissions Guard", () => {
permissionsCallback.mockImplementation((_provider) => true); permissionsCallback.mockImplementation((_provider) => true);
const provider = providerFactory(); const provider = providerFactory();
providerService.get.calledWith(provider.id).mockResolvedValue(provider); providerService.get$.calledWith(provider.id, mockUserId).mockReturnValue(of(provider));
const actual = await TestBed.runInInjectionContext( const actual = await TestBed.runInInjectionContext(
async () => await providerPermissionsGuard(permissionsCallback)(route, state), async () => await providerPermissionsGuard(permissionsCallback)(route, state),
@@ -88,7 +107,7 @@ describe("Provider Permissions Guard", () => {
const permissionsCallback = jest.fn(); const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((_org) => false); permissionsCallback.mockImplementation((_org) => false);
const provider = providerFactory(); const provider = providerFactory();
providerService.get.calledWith(provider.id).mockResolvedValue(provider); providerService.get$.calledWith(provider.id, mockUserId).mockReturnValue(of(provider));
const actual = await TestBed.runInInjectionContext( const actual = await TestBed.runInInjectionContext(
async () => await providerPermissionsGuard(permissionsCallback)(route, state), async () => await providerPermissionsGuard(permissionsCallback)(route, state),
@@ -104,7 +123,7 @@ describe("Provider Permissions Guard", () => {
type: ProviderUserType.ServiceUser, type: ProviderUserType.ServiceUser,
enabled: false, enabled: false,
}); });
providerService.get.calledWith(org.id).mockResolvedValue(org); providerService.get$.calledWith(org.id, mockUserId).mockReturnValue(of(org));
const actual = await TestBed.runInInjectionContext( const actual = await TestBed.runInInjectionContext(
async () => await providerPermissionsGuard()(route, state), async () => await providerPermissionsGuard()(route, state),
@@ -118,7 +137,7 @@ describe("Provider Permissions Guard", () => {
type: ProviderUserType.ProviderAdmin, type: ProviderUserType.ProviderAdmin,
enabled: false, enabled: false,
}); });
providerService.get.calledWith(org.id).mockResolvedValue(org); providerService.get$.calledWith(org.id, mockUserId).mockReturnValue(of(org));
const actual = await TestBed.runInInjectionContext( const actual = await TestBed.runInInjectionContext(
async () => await providerPermissionsGuard()(route, state), async () => await providerPermissionsGuard()(route, state),

View File

@@ -7,9 +7,12 @@ import {
Router, Router,
RouterStateSnapshot, RouterStateSnapshot,
} from "@angular/router"; } from "@angular/router";
import { firstValueFrom, switchMap } from "rxjs";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
@@ -40,8 +43,14 @@ export function providerPermissionsGuard(
const router = inject(Router); const router = inject(Router);
const i18nService = inject(I18nService); const i18nService = inject(I18nService);
const toastService = inject(ToastService); const toastService = inject(ToastService);
const accountService = inject(AccountService);
const provider = await providerService.get(route.params.providerId); const provider = await firstValueFrom(
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => providerService.get$(route.params.providerId, userId)),
),
);
if (provider == null) { if (provider == null) {
return router.createUrlTree(["/"]); return router.createUrlTree(["/"]);
} }

View File

@@ -2,12 +2,14 @@
// @ts-strict-ignore // @ts-strict-ignore
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, switchMap } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EventResponse } from "@bitwarden/common/models/response/event.response"; import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -64,7 +66,13 @@ export class EventsComponent extends BaseEventsComponent implements OnInit {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => { this.route.parent.parent.params.subscribe(async (params) => {
this.providerId = params.providerId; this.providerId = params.providerId;
const provider = await this.providerService.get(this.providerId); const provider = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.providerService.get$(this.providerId, userId)),
),
);
if (provider == null || !provider.useEvents) { if (provider == null || !provider.useEvents) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@@ -3,7 +3,7 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, lastValueFrom, switchMap } from "rxjs"; import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs";
import { first } from "rxjs/operators"; import { first } from "rxjs/operators";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
@@ -14,6 +14,8 @@ import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admi
import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request";
import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request"; import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -73,6 +75,7 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private providerService: ProviderService, private providerService: ProviderService,
private router: Router, private router: Router,
private accountService: AccountService,
) { ) {
super( super(
apiService, apiService,
@@ -96,7 +99,13 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
this.dataSource.filter = peopleFilter(queryParams.search, null); this.dataSource.filter = peopleFilter(queryParams.search, null);
this.providerId = urlParams.providerId; this.providerId = urlParams.providerId;
const provider = await this.providerService.get(this.providerId); const provider = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.providerService.get$(this.providerId, userId)),
),
);
if (!provider || !provider.canManageUsers) { if (!provider || !provider.canManageUsers) {
return await this.router.navigate(["../"], { relativeTo: this.activatedRoute }); return await this.router.navigate(["../"], { relativeTo: this.activatedRoute });
} }

View File

@@ -11,6 +11,8 @@ import { BusinessUnitPortalLogo, Icon, ProviderPortalLogo } from "@bitwarden/ass
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-console/enums"; import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { IconModule } from "@bitwarden/components"; import { IconModule } from "@bitwarden/components";
@@ -56,6 +58,7 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
private providerService: ProviderService, private providerService: ProviderService,
private configService: ConfigService, private configService: ConfigService,
private providerWarningsService: ProviderWarningsService, private providerWarningsService: ProviderWarningsService,
private accountService: AccountService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -65,8 +68,11 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
map((params) => params.providerId), map((params) => params.providerId),
); );
this.provider$ = providerId$.pipe( this.provider$ = combineLatest([
switchMap((providerId) => this.providerService.get$(providerId)), providerId$,
this.accountService.activeAccount$.pipe(getUserId),
]).pipe(
switchMap(([providerId, userId]) => this.providerService.get$(providerId, userId)),
takeUntil(this.destroy$), takeUntil(this.destroy$),
); );

View File

@@ -1,3 +1,4 @@
@let providers = providers$ | async;
<app-header></app-header> <app-header></app-header>
<bit-container> <bit-container>

View File

@@ -1,9 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { map, Observable, switchMap, tap } from "rxjs";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -13,24 +16,27 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
standalone: false, standalone: false,
}) })
export class ProvidersComponent implements OnInit { export class ProvidersComponent implements OnInit {
providers: Provider[]; providers$: Observable<Provider[]>;
loaded = false; loaded = false;
actionPromise: Promise<any>; actionPromise: Promise<any>;
constructor( constructor(
private providerService: ProviderService, private providerService: ProviderService,
private i18nService: I18nService, private i18nService: I18nService,
private accountService: AccountService,
) {} ) {}
async ngOnInit() { ngOnInit() {
document.body.classList.remove("layout_frontend"); document.body.classList.remove("layout_frontend");
await this.load(); this.load();
} }
async load() { load() {
const providers = await this.providerService.getAll(); this.providers$ = this.accountService.activeAccount$.pipe(
providers.sort(Utils.getSortFunction(this.i18nService, "name")); getUserId,
this.providers = providers; switchMap((userId) => this.providerService.providers$(userId)),
this.loaded = true; map((p) => p.sort(Utils.getSortFunction(this.i18nService, "name"))),
tap(() => (this.loaded = true)),
);
} }
} }

View File

@@ -21,6 +21,8 @@ import {
} from "@bitwarden/common/admin-console/enums"; } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -87,9 +89,10 @@ export class ManageClientsComponent {
this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ?? this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ??
new Observable(); new Observable();
protected provider$ = this.providerId$.pipe( protected provider$ = combineLatest([
switchMap((providerId) => this.providerService.get$(providerId)), this.providerId$,
); this.accountService.activeAccount$.pipe(getUserId),
]).pipe(switchMap(([providerId, userId]) => this.providerService.get$(providerId, userId)));
protected isAdminOrServiceUser$ = this.provider$.pipe( protected isAdminOrServiceUser$ = this.provider$.pipe(
map( map(
@@ -126,6 +129,7 @@ export class ManageClientsComponent {
private webProviderService: WebProviderService, private webProviderService: WebProviderService,
private billingNotificationService: BillingNotificationService, private billingNotificationService: BillingNotificationService,
private configService: ConfigService, private configService: ConfigService,
private accountService: AccountService,
) { ) {
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
this.searchControl.setValue(queryParams.search); this.searchControl.setValue(queryParams.search);
@@ -133,7 +137,7 @@ export class ManageClientsComponent {
this.provider$ this.provider$
.pipe( .pipe(
map((provider: Provider) => { map((provider: Provider | undefined) => {
if (provider?.providerStatus !== ProviderStatusType.Billable) { if (provider?.providerStatus !== ProviderStatusType.Billable) {
return from( return from(
this.router.navigate(["../clients"], { this.router.navigate(["../clients"], {
@@ -158,7 +162,8 @@ export class ManageClientsComponent {
async load() { async load() {
try { try {
const providerId = await firstValueFrom(this.providerId$); const providerId = await firstValueFrom(this.providerId$);
const provider = await firstValueFrom(this.providerService.get$(providerId)); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const provider = await firstValueFrom(this.providerService.get$(providerId, userId));
if (provider?.providerType === ProviderType.BusinessUnit) { if (provider?.providerType === ProviderType.BusinessUnit) {
this.pageTitle = this.i18nService.t("businessUnits"); this.pageTitle = this.i18nService.t("businessUnits");
this.clientColumnHeader = this.i18nService.t("businessUnit"); this.clientColumnHeader = this.i18nService.t("businessUnit");

View File

@@ -4,11 +4,15 @@ import { firstValueFrom } from "rxjs";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderStatusType } from "@bitwarden/common/admin-console/enums"; import { ProviderStatusType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
export const hasConsolidatedBilling: CanActivateFn = async (route: ActivatedRouteSnapshot) => { export const hasConsolidatedBilling: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const providerService = inject(ProviderService); const providerService = inject(ProviderService);
const accountService = inject(AccountService);
const provider = await firstValueFrom(providerService.get$(route.params.providerId)); const userId = await firstValueFrom(getUserId(accountService.activeAccount$));
const provider = await firstValueFrom(providerService.get$(route.params.providerId, userId));
if (!provider || provider.providerStatus !== ProviderStatusType.Billable) { if (!provider || provider.providerStatus !== ProviderStatusType.Billable) {
return createUrlTreeFromSnapshot(route, ["/providers", route.params.providerId]); return createUrlTreeFromSnapshot(route, ["/providers", route.params.providerId]);

View File

@@ -83,8 +83,12 @@ const BANK_ACCOUNT_VERIFIED_COMMAND = new CommandDefinition<{
export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy {
private viewState$ = new BehaviorSubject<View | null>(null); private viewState$ = new BehaviorSubject<View | null>(null);
private provider$ = this.activatedRoute.params.pipe( private provider$ = combineLatest([
switchMap(({ providerId }) => this.providerService.get$(providerId)), this.activatedRoute.params,
this.accountService.activeAccount$.pipe(getUserId),
]).pipe(
switchMap(([{ providerId }, userId]) => this.providerService.get$(providerId, userId)),
filter((provider) => provider != null),
); );
private load$: Observable<View> = this.provider$.pipe( private load$: Observable<View> = this.provider$.pipe(

View File

@@ -123,7 +123,9 @@ export class AddAccountCreditDialogComponent implements OnInit {
this.formGroup.patchValue({ this.formGroup.patchValue({
creditAmount: 20.0, creditAmount: 20.0,
}); });
this.provider = await this.providerService.get(this.dialogParams.providerId); this.provider = await firstValueFrom(
this.providerService.get$(this.dialogParams.providerId, this.user.id),
);
payPalCustomField = "provider_id:" + this.provider.id; payPalCustomField = "provider_id:" + this.provider.id;
this.payPalConfig.subject = this.provider.name; this.payPalConfig.subject = this.provider.name;
} else { } else {

View File

@@ -5,8 +5,7 @@ import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider"; import { Provider } from "../models/domain/provider";
export abstract class ProviderService { export abstract class ProviderService {
abstract get$(id: string): Observable<Provider>; abstract providers$(userId: UserId): Observable<Provider[]>;
abstract get(id: string): Promise<Provider>; abstract get$(id: string, userId: UserId): Observable<Provider | undefined>;
abstract getAll(): Promise<Provider[]>; abstract save(providers: { [id: string]: ProviderData }, userId: UserId): Promise<any>;
abstract save(providers: { [id: string]: ProviderData }, userId?: UserId): Promise<any>;
} }

View File

@@ -1,7 +1,7 @@
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; import { FakeSingleUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils"; import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { import {
@@ -20,11 +20,11 @@ import { PROVIDERS, ProviderService } from "./provider.service";
* in state. This helper methods lets us build provider arrays in tests * in state. This helper methods lets us build provider arrays in tests
* and easily map them to records before storing them in state. * and easily map them to records before storing them in state.
*/ */
function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> { function arrayToRecord(input: ProviderData[] | undefined): Record<string, ProviderData> | null {
if (input == null) { if (input == null || input.length < 1) {
return undefined; return null;
} }
return Object.fromEntries(input?.map((i) => [i.id, i])); return Object.fromEntries(input.map((i) => [i.id, i]));
} }
/** /**
@@ -39,7 +39,7 @@ function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> {
*/ */
function buildMockProviders(count = 1, suffix?: string): ProviderData[] { function buildMockProviders(count = 1, suffix?: string): ProviderData[] {
if (count < 1) { if (count < 1) {
return undefined; return [];
} }
function buildMockProvider(id: string, name: string): ProviderData { function buildMockProvider(id: string, name: string): ProviderData {
@@ -87,30 +87,28 @@ describe("ProviderService", () => {
let fakeAccountService: FakeAccountService; let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider; let fakeStateProvider: FakeStateProvider;
let fakeUserState: FakeSingleUserState<Record<string, ProviderData>>; let fakeUserState: FakeSingleUserState<Record<string, ProviderData>>;
let fakeActiveUserState: FakeActiveUserState<Record<string, ProviderData>>;
beforeEach(async () => { beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId); fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService); fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeUserState = fakeStateProvider.singleUser.getFake(fakeUserId, PROVIDERS); fakeUserState = fakeStateProvider.singleUser.getFake(fakeUserId, PROVIDERS);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(PROVIDERS);
providerService = new ProviderService(fakeStateProvider); providerService = new ProviderService(fakeStateProvider);
}); });
describe("getAll()", () => { describe("providers$()", () => {
it("Returns an array of all providers stored in state", async () => { it("Returns an array of all providers stored in state", async () => {
const mockData: ProviderData[] = buildMockProviders(5); const mockData = buildMockProviders(5);
fakeUserState.nextState(arrayToRecord(mockData)); fakeUserState.nextState(arrayToRecord(mockData));
const providers = await providerService.getAll(); const providers = await firstValueFrom(providerService.providers$(fakeUserId));
expect(providers).toHaveLength(5); expect(providers).toHaveLength(5);
expect(providers).toEqual(mockData.map((x) => new Provider(x))); expect(providers).toEqual(mockData.map((x) => new Provider(x)));
}); });
it("Returns an empty array if no providers are found in state", async () => { it("Returns an empty array if no providers are found in state", async () => {
const mockData: ProviderData[] = undefined; let mockData;
fakeUserState.nextState(arrayToRecord(mockData)); fakeUserState.nextState(arrayToRecord(mockData));
const result = await providerService.getAll(); const result = await firstValueFrom(providerService.providers$(fakeUserId));
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
}); });
@@ -119,50 +117,38 @@ describe("ProviderService", () => {
it("Returns an observable of a single provider from state that matches the specified id", async () => { it("Returns an observable of a single provider from state that matches the specified id", async () => {
const mockData = buildMockProviders(5); const mockData = buildMockProviders(5);
fakeUserState.nextState(arrayToRecord(mockData)); fakeUserState.nextState(arrayToRecord(mockData));
const result = providerService.get$(mockData[3].id); const result = providerService.get$(mockData[3].id, fakeUserId);
const provider = await firstValueFrom(result); const provider = await firstValueFrom(result);
expect(provider).toEqual(new Provider(mockData[3])); expect(provider).toEqual(new Provider(mockData[3]));
}); });
it("Returns an observable of undefined if the specified provider is not found", async () => { it("Returns an observable of undefined if the specified provider is not found", async () => {
const result = providerService.get$("this-provider-does-not-exist"); const result = providerService.get$("this-provider-does-not-exist", fakeUserId);
const provider = await firstValueFrom(result); const provider = await firstValueFrom(result);
expect(provider).toBe(undefined); expect(provider).toBe(undefined);
}); });
}); });
describe("get()", () => {
it("Returns a single provider from state that matches the specified id", async () => {
const mockData = buildMockProviders(5);
fakeUserState.nextState(arrayToRecord(mockData));
const result = await providerService.get(mockData[3].id);
expect(result).toEqual(new Provider(mockData[3]));
});
it("Returns undefined if the specified provider id is not found", async () => {
const result = await providerService.get("this-provider-does-not-exist");
expect(result).toBe(undefined);
});
});
describe("save()", () => { describe("save()", () => {
it("replaces the entire provider list in state for the active user", async () => { it("replaces the entire provider list in state for the specified user", async () => {
const originalData = buildMockProviders(10); const originalData = buildMockProviders(10);
fakeUserState.nextState(arrayToRecord(originalData)); fakeUserState.nextState(arrayToRecord(originalData));
const newData = arrayToRecord(buildMockProviders(10, "newData")); const newData = arrayToRecord(buildMockProviders(10, "newData"));
await providerService.save(newData); if (newData) {
await providerService.save(newData, fakeUserId);
}
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, newData]); expect(fakeUserState.nextMock).toHaveBeenCalledWith(newData);
}); });
// This is more or less a test for logouts // This is more or less a test for logouts
it("can replace state with null", async () => { it("can replace state with null", async () => {
const originalData = buildMockProviders(2); const originalData = buildMockProviders(2);
fakeActiveUserState.nextState(arrayToRecord(originalData)); fakeUserState.nextState(arrayToRecord(originalData));
await providerService.save(null); await providerService.save(null, fakeUserId);
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, null]); expect(fakeUserState.nextMock).toHaveBeenCalledWith(null);
}); });
}); });
}); });

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line import { map, Observable } from "rxjs";
// @ts-strict-ignore
import { firstValueFrom, map, Observable, of, switchMap, take } from "rxjs";
import { getById } from "../../platform/misc";
import { PROVIDERS_DISK, StateProvider, UserKeyDefinition } from "../../platform/state"; import { PROVIDERS_DISK, StateProvider, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service"; import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service";
@@ -13,46 +12,26 @@ export const PROVIDERS = UserKeyDefinition.record<ProviderData>(PROVIDERS_DISK,
clearOn: ["logout"], clearOn: ["logout"],
}); });
function mapToSingleProvider(providerId: string) {
return map<Provider[], Provider>((providers) => providers?.find((p) => p.id === providerId));
}
export class ProviderService implements ProviderServiceAbstraction { export class ProviderService implements ProviderServiceAbstraction {
constructor(private stateProvider: StateProvider) {} constructor(private stateProvider: StateProvider) {}
private providers$(userId?: UserId): Observable<Provider[] | undefined> { providers$(userId: UserId): Observable<Provider[]> {
// FIXME: Can be replaced with `getUserStateOrDefault$` if we weren't trying to pick this. return this.stateProvider
return ( .getUser(userId, PROVIDERS)
userId != null .state$.pipe(this.mapProviderRecordToArray());
? this.stateProvider.getUser(userId, PROVIDERS).state$
: this.stateProvider.activeUserId$.pipe(
take(1),
switchMap((userId) =>
userId != null ? this.stateProvider.getUser(userId, PROVIDERS).state$ : of(null),
),
)
).pipe(this.mapProviderRecordToArray());
} }
private mapProviderRecordToArray() { private mapProviderRecordToArray() {
return map<Record<string, ProviderData>, Provider[]>((providers) => return map<Record<string, ProviderData> | null, Provider[]>((providers) =>
Object.values(providers ?? {})?.map((o) => new Provider(o)), Object.values(providers ?? {}).map((o) => new Provider(o)),
); );
} }
get$(id: string): Observable<Provider> { get$(id: string, userId: UserId): Observable<Provider | undefined> {
return this.providers$().pipe(mapToSingleProvider(id)); return this.providers$(userId).pipe(getById(id));
} }
async get(id: string): Promise<Provider> { async save(providers: { [id: string]: ProviderData }, userId: UserId) {
return await firstValueFrom(this.providers$().pipe(mapToSingleProvider(id)));
}
async getAll(): Promise<Provider[]> {
return await firstValueFrom(this.providers$());
}
async save(providers: { [id: string]: ProviderData }, userId?: UserId) {
await this.stateProvider.setUserState(PROVIDERS, providers, userId); await this.stateProvider.setUserState(PROVIDERS, providers, userId);
} }
} }