mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
[PM-13128] Enable Breadcrumb Policies (#13584)
* [PM-13128] Enable Breadcrumb Policies * [PM-13128] Enable Breadcrumb Policies * [PM-13128] wip * [PM-13128] wip * [PM-13128] wip * [PM-13128] wip * remove dead code * wip * wip * wip * refactor * Fix for providers * revert to functional auth guard * change prerequisite to info variant * address comment * r * r * r * tests * r * r * fix tests * feedback * fix tests * fix tests * Rename upselling to breadcrumbing * Address feedback * Fix build & tests * Make the guard callback use Observable instead of a promise * Pm 13128 suggestions (#14041) * Rename new enum value * Show the upgrade button when breadcrumbing is enabled * Show mouse pointer when cursor is hovered above badge * Do not make the dialogs overlap * Align badge middle * Gap * Badge should be a `button` instead of `span` * missing button@type --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: Alex Morask <amorask@bitwarden.com>
This commit is contained in:
@@ -100,20 +100,44 @@ describe("Organization Permissions Guard", () => {
|
|||||||
|
|
||||||
it("permits navigation if the user has permissions", async () => {
|
it("permits navigation if the user has permissions", async () => {
|
||||||
const permissionsCallback = jest.fn();
|
const permissionsCallback = jest.fn();
|
||||||
permissionsCallback.mockImplementation((_org) => true);
|
permissionsCallback.mockReturnValue(true);
|
||||||
|
|
||||||
const actual = await TestBed.runInInjectionContext(
|
const actual = await TestBed.runInInjectionContext(
|
||||||
async () => await organizationPermissionsGuard(permissionsCallback)(route, state),
|
async () => await organizationPermissionsGuard(permissionsCallback)(route, state),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(permissionsCallback).toHaveBeenCalledWith(orgFactory({ id: targetOrgId }));
|
expect(permissionsCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(actual).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a Promise returned from the callback", async () => {
|
||||||
|
const permissionsCallback = jest.fn();
|
||||||
|
permissionsCallback.mockReturnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
const actual = await TestBed.runInInjectionContext(() =>
|
||||||
|
organizationPermissionsGuard(permissionsCallback)(route, state),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(permissionsCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(actual).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an Observable returned from the callback", async () => {
|
||||||
|
const permissionsCallback = jest.fn();
|
||||||
|
permissionsCallback.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
const actual = await TestBed.runInInjectionContext(() =>
|
||||||
|
organizationPermissionsGuard(permissionsCallback)(route, state),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(permissionsCallback).toHaveBeenCalledTimes(1);
|
||||||
expect(actual).toBe(true);
|
expect(actual).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("if the user does not have permissions", () => {
|
describe("if the user does not have permissions", () => {
|
||||||
it("and there is no Item ID, block navigation", async () => {
|
it("and there is no Item ID, block navigation", async () => {
|
||||||
const permissionsCallback = jest.fn();
|
const permissionsCallback = jest.fn();
|
||||||
permissionsCallback.mockImplementation((_org) => false);
|
permissionsCallback.mockReturnValue(false);
|
||||||
|
|
||||||
state = mock<RouterStateSnapshot>({
|
state = mock<RouterStateSnapshot>({
|
||||||
root: mock<ActivatedRouteSnapshot>({
|
root: mock<ActivatedRouteSnapshot>({
|
||||||
|
|||||||
@@ -1,13 +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 { inject } from "@angular/core";
|
import { EnvironmentInjector, inject, runInInjectionContext } from "@angular/core";
|
||||||
import {
|
import {
|
||||||
ActivatedRouteSnapshot,
|
ActivatedRouteSnapshot,
|
||||||
CanActivateFn,
|
CanActivateFn,
|
||||||
Router,
|
Router,
|
||||||
RouterStateSnapshot,
|
RouterStateSnapshot,
|
||||||
} from "@angular/router";
|
} from "@angular/router";
|
||||||
import { firstValueFrom, switchMap } from "rxjs";
|
import { firstValueFrom, isObservable, Observable, switchMap } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
canAccessOrgAdmin,
|
canAccessOrgAdmin,
|
||||||
@@ -42,7 +42,9 @@ import { ToastService } from "@bitwarden/components";
|
|||||||
* proceeds as expected.
|
* proceeds as expected.
|
||||||
*/
|
*/
|
||||||
export function organizationPermissionsGuard(
|
export function organizationPermissionsGuard(
|
||||||
permissionsCallback?: (organization: Organization) => boolean,
|
permissionsCallback?: (
|
||||||
|
organization: Organization,
|
||||||
|
) => boolean | Promise<boolean> | Observable<boolean>,
|
||||||
): CanActivateFn {
|
): CanActivateFn {
|
||||||
return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
|
return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
@@ -51,6 +53,7 @@ export function organizationPermissionsGuard(
|
|||||||
const i18nService = inject(I18nService);
|
const i18nService = inject(I18nService);
|
||||||
const syncService = inject(SyncService);
|
const syncService = inject(SyncService);
|
||||||
const accountService = inject(AccountService);
|
const accountService = inject(AccountService);
|
||||||
|
const environmentInjector = inject(EnvironmentInjector);
|
||||||
|
|
||||||
// TODO: We need to fix issue once and for all.
|
// TODO: We need to fix issue once and for all.
|
||||||
if ((await syncService.getLastSync()) == null) {
|
if ((await syncService.getLastSync()) == null) {
|
||||||
@@ -78,7 +81,22 @@ export function organizationPermissionsGuard(
|
|||||||
return router.createUrlTree(["/"]);
|
return router.createUrlTree(["/"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPermissions = permissionsCallback == null || permissionsCallback(org);
|
if (permissionsCallback == null) {
|
||||||
|
// No additional permission checks required, allow navigation
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackResult = runInInjectionContext(environmentInjector, () =>
|
||||||
|
permissionsCallback(org),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasPermissions = isObservable(callbackResult)
|
||||||
|
? await firstValueFrom(callbackResult) // handles observables
|
||||||
|
: await Promise.resolve(callbackResult); // handles promises and boolean values
|
||||||
|
|
||||||
|
if (hasPermissions !== true && hasPermissions !== false) {
|
||||||
|
throw new Error("Permission callback did not resolve to a boolean.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasPermissions) {
|
if (!hasPermissions) {
|
||||||
// Handle linkable ciphers for organizations the user only has view access to
|
// Handle linkable ciphers for organizations the user only has view access to
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
<bit-nav-item
|
<bit-nav-item
|
||||||
[text]="'policies' | i18n"
|
[text]="'policies' | i18n"
|
||||||
route="settings/policies"
|
route="settings/policies"
|
||||||
*ngIf="organization.canManagePolicies"
|
*ngIf="canShowPoliciesTab$ | async"
|
||||||
></bit-nav-item>
|
></bit-nav-item>
|
||||||
<bit-nav-item
|
<bit-nav-item
|
||||||
[text]="'twoStepLogin' | i18n"
|
[text]="'twoStepLogin' | i18n"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/
|
|||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
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 { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||||
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";
|
||||||
@@ -68,6 +69,7 @@ export class OrganizationLayoutComponent implements OnInit {
|
|||||||
showAccountDeprovisioningBanner$: Observable<boolean>;
|
showAccountDeprovisioningBanner$: Observable<boolean>;
|
||||||
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
|
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
|
||||||
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
|
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
|
||||||
|
protected canShowPoliciesTab$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -79,6 +81,7 @@ export class OrganizationLayoutComponent implements OnInit {
|
|||||||
protected bannerService: AccountDeprovisioningBannerService,
|
protected bannerService: AccountDeprovisioningBannerService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
|
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
|
||||||
|
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
@@ -148,6 +151,18 @@ export class OrganizationLayoutComponent implements OnInit {
|
|||||||
))
|
))
|
||||||
? "claimedDomains"
|
? "claimedDomains"
|
||||||
: "domainVerification";
|
: "domainVerification";
|
||||||
|
|
||||||
|
this.canShowPoliciesTab$ = this.organization$.pipe(
|
||||||
|
switchMap((organization) =>
|
||||||
|
this.organizationBillingService
|
||||||
|
.isBreadcrumbingPoliciesEnabled$(organization)
|
||||||
|
.pipe(
|
||||||
|
map(
|
||||||
|
(isBreadcrumbingEnabled) => isBreadcrumbingEnabled || organization.canManagePolicies,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
canShowVaultTab(organization: Organization): boolean {
|
canShowVaultTab(organization: Organization): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
<app-header></app-header>
|
<app-header>
|
||||||
|
@let organization = organization$ | async;
|
||||||
|
<button
|
||||||
|
bitBadge
|
||||||
|
class="!tw-align-middle"
|
||||||
|
(click)="changePlan(organization)"
|
||||||
|
*ngIf="isBreadcrumbingEnabled$ | async"
|
||||||
|
slot="title-suffix"
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
{{ "upgrade" | i18n }}
|
||||||
|
</button>
|
||||||
|
</app-header>
|
||||||
|
|
||||||
<bit-container>
|
<bit-container>
|
||||||
<ng-container *ngIf="loading">
|
<ng-container *ngIf="loading">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
|
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
|
||||||
import { ActivatedRoute } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { firstValueFrom, lastValueFrom } from "rxjs";
|
import { firstValueFrom, lastValueFrom, map, Observable, switchMap } from "rxjs";
|
||||||
import { first, map } from "rxjs/operators";
|
import { first } from "rxjs/operators";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getOrganizationById,
|
getOrganizationById,
|
||||||
@@ -14,10 +14,17 @@ import { PolicyType } 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 { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
import {
|
||||||
|
ChangePlanDialogResultType,
|
||||||
|
openChangePlanDialog,
|
||||||
|
} from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component";
|
||||||
|
import { All } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
||||||
|
|
||||||
import { PolicyListService } from "../../core/policy-list.service";
|
import { PolicyListService } from "../../core/policy-list.service";
|
||||||
import { BasePolicy } from "../policies";
|
import { BasePolicy } from "../policies";
|
||||||
|
import { CollectionDialogTabType } from "../shared/components/collection-dialog";
|
||||||
|
|
||||||
import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component";
|
import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component";
|
||||||
|
|
||||||
@@ -32,17 +39,19 @@ export class PoliciesComponent implements OnInit {
|
|||||||
loading = true;
|
loading = true;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
policies: BasePolicy[];
|
policies: BasePolicy[];
|
||||||
organization: Organization;
|
protected organization$: Observable<Organization>;
|
||||||
|
|
||||||
private orgPolicies: PolicyResponse[];
|
private orgPolicies: PolicyResponse[];
|
||||||
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
|
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
|
||||||
|
protected isBreadcrumbingEnabled$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private organizationService: OrganizationService,
|
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
|
private organizationService: OrganizationService,
|
||||||
private policyApiService: PolicyApiServiceAbstraction,
|
private policyApiService: PolicyApiServiceAbstraction,
|
||||||
private policyListService: PolicyListService,
|
private policyListService: PolicyListService,
|
||||||
|
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -53,11 +62,9 @@ export class PoliciesComponent implements OnInit {
|
|||||||
const userId = await firstValueFrom(
|
const userId = await firstValueFrom(
|
||||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||||
);
|
);
|
||||||
this.organization = await firstValueFrom(
|
this.organization$ = this.organizationService
|
||||||
this.organizationService
|
.organizations$(userId)
|
||||||
.organizations$(userId)
|
.pipe(getOrganizationById(this.organizationId));
|
||||||
.pipe(getOrganizationById(this.organizationId)),
|
|
||||||
);
|
|
||||||
this.policies = this.policyListService.getPolicies();
|
this.policies = this.policyListService.getPolicies();
|
||||||
|
|
||||||
await this.load();
|
await this.load();
|
||||||
@@ -91,7 +98,11 @@ export class PoliciesComponent implements OnInit {
|
|||||||
this.orgPolicies.forEach((op) => {
|
this.orgPolicies.forEach((op) => {
|
||||||
this.policiesEnabledMap.set(op.type, op.enabled);
|
this.policiesEnabledMap.set(op.type, op.enabled);
|
||||||
});
|
});
|
||||||
|
this.isBreadcrumbingEnabled$ = this.organization$.pipe(
|
||||||
|
switchMap((organization) =>
|
||||||
|
this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization),
|
||||||
|
),
|
||||||
|
);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +115,34 @@ export class PoliciesComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await lastValueFrom(dialogRef.closed);
|
const result = await lastValueFrom(dialogRef.closed);
|
||||||
if (result === PolicyEditDialogResult.Saved) {
|
switch (result) {
|
||||||
await this.load();
|
case PolicyEditDialogResult.Saved:
|
||||||
|
await this.load();
|
||||||
|
break;
|
||||||
|
case PolicyEditDialogResult.UpgradePlan:
|
||||||
|
await this.changePlan(await firstValueFrom(this.organization$));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly CollectionDialogTabType = CollectionDialogTabType;
|
||||||
|
protected readonly All = All;
|
||||||
|
|
||||||
|
protected async changePlan(organization: Organization) {
|
||||||
|
const reference = openChangePlanDialog(this.dialogService, {
|
||||||
|
data: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
subscription: null,
|
||||||
|
productTierType: organization.productTierType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await lastValueFrom(reference.closed);
|
||||||
|
|
||||||
|
if (result === ChangePlanDialogResultType.Closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
<bit-dialog [loading]="loading" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
|
<bit-dialog [loading]="loading" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
|
||||||
|
<ng-container bitDialogTitle>
|
||||||
|
<button
|
||||||
|
bitBadge
|
||||||
|
class="!tw-align-middle"
|
||||||
|
(click)="upgradePlan()"
|
||||||
|
*ngIf="isBreadcrumbingEnabled$ | async"
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
{{ "planNameEnterprise" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
<ng-container bitDialogContent>
|
<ng-container bitDialogContent>
|
||||||
<div *ngIf="loading">
|
<div *ngIf="loading">
|
||||||
<i
|
<i
|
||||||
@@ -16,6 +28,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container bitDialogFooter>
|
<ng-container bitDialogFooter>
|
||||||
<button
|
<button
|
||||||
|
*ngIf="!(isBreadcrumbingEnabled$ | async); else breadcrumbing"
|
||||||
bitButton
|
bitButton
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
[disabled]="saveDisabled$ | async"
|
[disabled]="saveDisabled$ | async"
|
||||||
@@ -24,6 +37,11 @@
|
|||||||
>
|
>
|
||||||
{{ "save" | i18n }}
|
{{ "save" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
|
<ng-template #breadcrumbing>
|
||||||
|
<button bitButton buttonType="primary" bitFormButton type="button" (click)="upgradePlan()">
|
||||||
|
{{ "upgrade" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
<button bitButton buttonType="secondary" bitDialogClose type="button">
|
<button bitButton buttonType="secondary" bitDialogClose type="button">
|
||||||
{{ "cancel" | i18n }}
|
{{ "cancel" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,12 +9,20 @@ import {
|
|||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { FormBuilder } from "@angular/forms";
|
import { FormBuilder } from "@angular/forms";
|
||||||
import { Observable, map } from "rxjs";
|
import { map, Observable, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getOrganizationById,
|
||||||
|
OrganizationService,
|
||||||
|
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import {
|
import {
|
||||||
DIALOG_DATA,
|
DIALOG_DATA,
|
||||||
@@ -35,6 +43,7 @@ export type PolicyEditDialogData = {
|
|||||||
|
|
||||||
export enum PolicyEditDialogResult {
|
export enum PolicyEditDialogResult {
|
||||||
Saved = "saved",
|
Saved = "saved",
|
||||||
|
UpgradePlan = "upgrade-plan",
|
||||||
}
|
}
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-policy-edit",
|
selector: "app-policy-edit",
|
||||||
@@ -48,22 +57,28 @@ export class PolicyEditComponent implements AfterViewInit {
|
|||||||
loading = true;
|
loading = true;
|
||||||
enabled = false;
|
enabled = false;
|
||||||
saveDisabled$: Observable<boolean>;
|
saveDisabled$: Observable<boolean>;
|
||||||
defaultTypes: any[];
|
|
||||||
policyComponent: BasePolicyComponent;
|
policyComponent: BasePolicyComponent;
|
||||||
|
|
||||||
private policyResponse: PolicyResponse;
|
private policyResponse: PolicyResponse;
|
||||||
formGroup = this.formBuilder.group({
|
formGroup = this.formBuilder.group({
|
||||||
enabled: [this.enabled],
|
enabled: [this.enabled],
|
||||||
});
|
});
|
||||||
|
protected organization$: Observable<Organization>;
|
||||||
|
protected isBreadcrumbingEnabled$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
|
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
|
||||||
|
private accountService: AccountService,
|
||||||
private policyApiService: PolicyApiServiceAbstraction,
|
private policyApiService: PolicyApiServiceAbstraction,
|
||||||
|
private organizationService: OrganizationService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private dialogRef: DialogRef<PolicyEditDialogResult>,
|
private dialogRef: DialogRef<PolicyEditDialogResult>,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
|
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get policy(): BasePolicy {
|
get policy(): BasePolicy {
|
||||||
return this.data.policy;
|
return this.data.policy;
|
||||||
}
|
}
|
||||||
@@ -97,6 +112,16 @@ export class PolicyEditComponent implements AfterViewInit {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.organization$ = this.accountService.activeAccount$.pipe(
|
||||||
|
getUserId,
|
||||||
|
switchMap((userId) => this.organizationService.organizations$(userId)),
|
||||||
|
getOrganizationById(this.data.organizationId),
|
||||||
|
);
|
||||||
|
this.isBreadcrumbingEnabled$ = this.organization$.pipe(
|
||||||
|
switchMap((organization) =>
|
||||||
|
this.organizationBillingService.isBreadcrumbingPoliciesEnabled$(organization),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
submit = async () => {
|
submit = async () => {
|
||||||
@@ -119,4 +144,8 @@ export class PolicyEditComponent implements AfterViewInit {
|
|||||||
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
|
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
|
||||||
return dialogService.open<PolicyEditDialogResult>(PolicyEditComponent, config);
|
return dialogService.open<PolicyEditDialogResult>(PolicyEditComponent, config);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
protected upgradePlan(): void {
|
||||||
|
this.dialogRef.close(PolicyEditDialogResult.UpgradePlan);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { NgModule } from "@angular/core";
|
import { NgModule, inject } from "@angular/core";
|
||||||
import { RouterModule, Routes } from "@angular/router";
|
import { RouterModule, Routes } from "@angular/router";
|
||||||
|
import { map } from "rxjs";
|
||||||
|
|
||||||
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
|
|
||||||
import { organizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard";
|
import { organizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard";
|
||||||
import { organizationRedirectGuard } from "../../organizations/guards/org-redirect.guard";
|
import { organizationRedirectGuard } from "../../organizations/guards/org-redirect.guard";
|
||||||
@@ -41,7 +43,14 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "policies",
|
path: "policies",
|
||||||
component: PoliciesComponent,
|
component: PoliciesComponent,
|
||||||
canActivate: [organizationPermissionsGuard((org) => org.canManagePolicies)],
|
canActivate: [
|
||||||
|
organizationPermissionsGuard((o: Organization) => {
|
||||||
|
const organizationBillingService = inject(OrganizationBillingServiceAbstraction);
|
||||||
|
return organizationBillingService
|
||||||
|
.isBreadcrumbingPoliciesEnabled$(o)
|
||||||
|
.pipe(map((isBreadcrumbingEnabled) => o.canManagePolicies || isBreadcrumbingEnabled));
|
||||||
|
}),
|
||||||
|
],
|
||||||
data: {
|
data: {
|
||||||
titleId: "policies",
|
titleId: "policies",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<h1
|
<h1
|
||||||
bitTypography="h1"
|
bitTypography="h1"
|
||||||
noMargin
|
noMargin
|
||||||
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex"
|
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1"
|
||||||
[title]="title || (routeData.titleId | i18n)"
|
[title]="title || (routeData.titleId | i18n)"
|
||||||
>
|
>
|
||||||
<div class="tw-truncate">
|
<div class="tw-truncate">
|
||||||
|
|||||||
@@ -1255,6 +1255,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
I18nServiceAbstraction,
|
I18nServiceAbstraction,
|
||||||
OrganizationApiServiceAbstraction,
|
OrganizationApiServiceAbstraction,
|
||||||
SyncService,
|
SyncService,
|
||||||
|
ConfigService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// 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 { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
|
||||||
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
|
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
|
||||||
import { InitiationPath } from "../../models/request/reference-event.request";
|
import { InitiationPath } from "../../models/request/reference-event.request";
|
||||||
@@ -59,4 +62,10 @@ export abstract class OrganizationBillingServiceAbstraction {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
subscription: SubscriptionInformation,
|
subscription: SubscriptionInformation,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria.
|
||||||
|
* @param organization
|
||||||
|
*/
|
||||||
|
abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { firstValueFrom, of } from "rxjs";
|
||||||
|
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
|
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||||
|
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
describe("BillingAccountProfileStateService", () => {
|
||||||
|
let apiService: jest.Mocked<ApiService>;
|
||||||
|
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
|
||||||
|
let keyService: jest.Mocked<KeyService>;
|
||||||
|
let encryptService: jest.Mocked<EncryptService>;
|
||||||
|
let i18nService: jest.Mocked<I18nService>;
|
||||||
|
let organizationApiService: jest.Mocked<OrganizationApiService>;
|
||||||
|
let syncService: jest.Mocked<SyncService>;
|
||||||
|
let configService: jest.Mocked<ConfigService>;
|
||||||
|
|
||||||
|
let sut: OrganizationBillingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiService = mock<ApiService>();
|
||||||
|
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
keyService = mock<KeyService>();
|
||||||
|
encryptService = mock<EncryptService>();
|
||||||
|
i18nService = mock<I18nService>();
|
||||||
|
organizationApiService = mock<OrganizationApiService>();
|
||||||
|
syncService = mock<SyncService>();
|
||||||
|
configService = mock<ConfigService>();
|
||||||
|
|
||||||
|
sut = new OrganizationBillingService(
|
||||||
|
apiService,
|
||||||
|
billingApiService,
|
||||||
|
keyService,
|
||||||
|
encryptService,
|
||||||
|
i18nService,
|
||||||
|
organizationApiService,
|
||||||
|
syncService,
|
||||||
|
configService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
return jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBreadcrumbingPoliciesEnabled", () => {
|
||||||
|
it("returns false when feature flag is disabled", async () => {
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
const org = {
|
||||||
|
isProviderUser: false,
|
||||||
|
canEditSubscription: true,
|
||||||
|
productTierType: ProductTierType.Teams,
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||||
|
expect(actual).toBe(false);
|
||||||
|
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||||
|
FeatureFlag.PM12276_BreadcrumbEventLogs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when organization belongs to a provider", async () => {
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
const org = {
|
||||||
|
isProviderUser: true,
|
||||||
|
canEditSubscription: true,
|
||||||
|
productTierType: ProductTierType.Teams,
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||||
|
expect(actual).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when cannot edit subscription", async () => {
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
const org = {
|
||||||
|
isProviderUser: false,
|
||||||
|
canEditSubscription: false,
|
||||||
|
productTierType: ProductTierType.Teams,
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||||
|
expect(actual).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Teams", ProductTierType.Teams],
|
||||||
|
["TeamsStarter", ProductTierType.TeamsStarter],
|
||||||
|
])("returns true when all conditions are met with %s tier", async (_, productTierType) => {
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
const org = {
|
||||||
|
isProviderUser: false,
|
||||||
|
canEditSubscription: true,
|
||||||
|
productTierType: productTierType,
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||||
|
expect(actual).toBe(true);
|
||||||
|
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||||
|
FeatureFlag.PM12276_BreadcrumbEventLogs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when product tier is not supported", async () => {
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
const org = {
|
||||||
|
isProviderUser: false,
|
||||||
|
canEditSubscription: true,
|
||||||
|
productTierType: ProductTierType.Enterprise,
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||||
|
expect(actual).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all conditions false correctly", async () => {
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
const org = {
|
||||||
|
isProviderUser: true,
|
||||||
|
canEditSubscription: false,
|
||||||
|
productTierType: ProductTierType.Free,
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||||
|
expect(actual).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies feature flag is only called once", async () => {
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
const org = {
|
||||||
|
isProviderUser: false,
|
||||||
|
canEditSubscription: true,
|
||||||
|
productTierType: ProductTierType.Teams,
|
||||||
|
} as Organization;
|
||||||
|
|
||||||
|
await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||||
|
expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
// 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 { Observable, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
import { ApiService } from "../../abstractions/api.service";
|
import { ApiService } from "../../abstractions/api.service";
|
||||||
@@ -20,7 +25,7 @@ import {
|
|||||||
PlanInformation,
|
PlanInformation,
|
||||||
SubscriptionInformation,
|
SubscriptionInformation,
|
||||||
} from "../abstractions";
|
} from "../abstractions";
|
||||||
import { PlanType } from "../enums";
|
import { PlanType, ProductTierType } from "../enums";
|
||||||
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
|
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
|
||||||
import { PaymentSourceResponse } from "../models/response/payment-source.response";
|
import { PaymentSourceResponse } from "../models/response/payment-source.response";
|
||||||
|
|
||||||
@@ -40,6 +45,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
|||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private organizationApiService: OrganizationApiService,
|
private organizationApiService: OrganizationApiService,
|
||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
|
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
|
||||||
@@ -220,4 +226,29 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
|||||||
this.setPaymentInformation(request, subscription.payment);
|
this.setPaymentInformation(request, subscription.payment);
|
||||||
await this.billingApiService.restartSubscription(organizationId, request);
|
await this.billingApiService.restartSubscription(organizationId, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean> {
|
||||||
|
if (organization === null || organization === undefined) {
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe(
|
||||||
|
switchMap((featureFlagEnabled) => {
|
||||||
|
if (!featureFlagEnabled) {
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organization.isProviderUser || !organization.canEditSubscription) {
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter];
|
||||||
|
const isSupportedProduct = supportedProducts.some(
|
||||||
|
(product) => product === organization.productTierType,
|
||||||
|
);
|
||||||
|
|
||||||
|
return of(isSupportedProduct);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user