Merge ad8bb930c5
into c21a58f2fb
This commit is contained in:
commit
a47a44e095
|
@ -1,5 +1,9 @@
|
|||
<bit-layout variant="secondary">
|
||||
<nav slot="sidebar" *ngIf="organization$ | async as organization">
|
||||
<nav
|
||||
slot="sidebar"
|
||||
*ngIf="organization$ | async as organization"
|
||||
class="tw-flex tw-flex-col tw-h-full"
|
||||
>
|
||||
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'adminConsole' | i18n">
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
</a>
|
||||
|
@ -106,6 +110,8 @@
|
|||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
|
||||
<navigation-product-switcher></navigation-product-switcher>
|
||||
|
||||
<app-toggle-width></app-toggle-width>
|
||||
</nav>
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import { BannerModule, IconModule, LayoutComponent, NavigationModule } from "@bi
|
|||
|
||||
import { PaymentMethodWarningsModule } from "../../../billing/shared";
|
||||
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
|
||||
import { ProductSwitcherModule } from "../../../layouts/product-switcher/product-switcher.module";
|
||||
import { ToggleWidthComponent } from "../../../layouts/toggle-width.component";
|
||||
import { AdminConsoleLogo } from "../../icons/admin-console-logo";
|
||||
|
||||
|
@ -43,6 +44,7 @@ import { AdminConsoleLogo } from "../../icons/admin-console-logo";
|
|||
BannerModule,
|
||||
PaymentMethodWarningsModule,
|
||||
ToggleWidthComponent,
|
||||
ProductSwitcherModule,
|
||||
],
|
||||
})
|
||||
export class OrganizationLayoutComponent implements OnInit, OnDestroy {
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
<div class="tw-mt-auto">
|
||||
<bit-nav-item
|
||||
*ngFor="let product of accessibleProducts$ | async"
|
||||
[icon]="product.icon"
|
||||
[text]="product.name"
|
||||
[route]="product.appRoute"
|
||||
[hideActiveStyles]="true"
|
||||
class="tw-group"
|
||||
>
|
||||
<ng-container slot="end">
|
||||
<span class="tw-text-xxs tw-hidden group-hover:tw-block group-focus:tw-block">
|
||||
{{ "switch" | i18n }}
|
||||
</span>
|
||||
</ng-container>
|
||||
</bit-nav-item>
|
||||
<section
|
||||
*ngIf="((moreProducts$ | async) ?? []).length > 0"
|
||||
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-t-text-alt2"
|
||||
>
|
||||
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
|
||||
<a
|
||||
*ngFor="let more of moreProducts$ | async"
|
||||
[href]="more.marketingRoute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
|
||||
>
|
||||
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1"></i>
|
||||
<div>
|
||||
{{ more.navigationUIDetails?.name ?? more.name }}
|
||||
<div *ngIf="more.navigationUIDetails?.supportingText" class="tw-text-xs tw-font-normal">
|
||||
{{ more.navigationUIDetails.supportingText }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
|
@ -0,0 +1,170 @@
|
|||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BitIconButtonComponent } from "@bitwarden/components/src/icon-button/icon-button.component";
|
||||
import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component";
|
||||
|
||||
import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service";
|
||||
|
||||
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
|
||||
|
||||
describe("NavigationProductSwitcherComponent", () => {
|
||||
let fixture: ComponentFixture<NavigationProductSwitcherComponent>;
|
||||
let productSwitcherService: MockProxy<ProductSwitcherService>;
|
||||
|
||||
const mockProducts$ = new BehaviorSubject<{
|
||||
bento: ProductSwitcherItem[];
|
||||
other: ProductSwitcherItem[];
|
||||
}>({
|
||||
bento: [],
|
||||
other: [],
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
productSwitcherService = mock<ProductSwitcherService>();
|
||||
productSwitcherService.products$ = mockProducts$;
|
||||
mockProducts$.next({ bento: [], other: [] });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RouterModule],
|
||||
declarations: [
|
||||
NavigationProductSwitcherComponent,
|
||||
NavItemComponent,
|
||||
BitIconButtonComponent,
|
||||
I18nPipe,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ProductSwitcherService, useValue: productSwitcherService },
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: mock<ActivatedRoute>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(NavigationProductSwitcherComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe("other products", () => {
|
||||
it("links to `marketingRoute`", () => {
|
||||
mockProducts$.next({
|
||||
bento: [],
|
||||
other: [
|
||||
{
|
||||
isActive: false,
|
||||
name: "Other Product",
|
||||
icon: "bwi-lock",
|
||||
marketingRoute: "https://www.example.com/",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const link = fixture.nativeElement.querySelector("a");
|
||||
|
||||
expect(link.getAttribute("href")).toBe("https://www.example.com/");
|
||||
});
|
||||
|
||||
it("uses `navigationUIDetails` when available", () => {
|
||||
mockProducts$.next({
|
||||
bento: [],
|
||||
other: [
|
||||
{
|
||||
isActive: false,
|
||||
name: "Other Product",
|
||||
icon: "bwi-lock",
|
||||
marketingRoute: "https://www.example.com/",
|
||||
navigationUIDetails: { name: "Alternate name" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector("a").textContent.trim()).toBe("Alternate name");
|
||||
|
||||
mockProducts$.next({
|
||||
bento: [],
|
||||
other: [
|
||||
{
|
||||
isActive: false,
|
||||
name: "Other Product",
|
||||
icon: "bwi-lock",
|
||||
marketingRoute: "https://www.example.com/",
|
||||
navigationUIDetails: { name: "Alternate name", supportingText: "Supporting Text" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector("a").textContent.trim().replace(/\s+/g, " ")).toBe(
|
||||
"Alternate name Supporting Text",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("available products", () => {
|
||||
it("does not show active products", () => {
|
||||
mockProducts$.next({
|
||||
bento: [
|
||||
{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" },
|
||||
{ isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" },
|
||||
],
|
||||
other: [],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const links = fixture.nativeElement.querySelectorAll("a");
|
||||
|
||||
expect(links.length).toBe(1);
|
||||
|
||||
expect(links[0].textContent).toContain("Secret Manager");
|
||||
});
|
||||
|
||||
it("shows inactive products", () => {
|
||||
mockProducts$.next({
|
||||
bento: [
|
||||
{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" },
|
||||
{ isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" },
|
||||
],
|
||||
other: [],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const links = fixture.nativeElement.querySelectorAll("a");
|
||||
|
||||
expect(links.length).toBe(2);
|
||||
|
||||
expect(links[0].textContent).toContain("Password Manager");
|
||||
expect(links[1].textContent).toContain("Secret Manager");
|
||||
});
|
||||
});
|
||||
|
||||
it("links to `appRoute`", () => {
|
||||
mockProducts$.next({
|
||||
bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
|
||||
other: [],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const link = fixture.nativeElement.querySelector("a");
|
||||
|
||||
expect(link.getAttribute("href")).toBe("/vault");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
import { Component, HostBinding } from "@angular/core";
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service";
|
||||
|
||||
@Component({
|
||||
selector: "navigation-product-switcher",
|
||||
templateUrl: "./navigation-switcher.component.html",
|
||||
})
|
||||
export class NavigationProductSwitcherComponent {
|
||||
// Use margin-top: auto to push the component to the bottom of the parent flex container
|
||||
@HostBinding("style.margin-top") marginTop = "auto";
|
||||
|
||||
constructor(private productSwitcherService: ProductSwitcherService) {}
|
||||
|
||||
accessibleProducts$: Observable<ProductSwitcherItem[]> =
|
||||
this.productSwitcherService.products$.pipe(
|
||||
map((products) => (products.bento ?? []).filter((item) => !item.isActive)),
|
||||
);
|
||||
|
||||
moreProducts$: Observable<ProductSwitcherItem[]> = this.productSwitcherService.products$.pipe(
|
||||
map((products) => (products.other ?? []).filter((item) => !item.isActive)),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { applicationConfig, Meta, moduleMetadata, StoryFn } from "@storybook/angular";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
|
||||
|
||||
import { ProductSwitcherService } from "../shared/product-switcher.service";
|
||||
|
||||
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[mockOrgs]",
|
||||
})
|
||||
class MockOrganizationService implements Partial<OrganizationService> {
|
||||
private static _orgs = new BehaviorSubject<Organization[]>([]);
|
||||
organizations$ = MockOrganizationService._orgs; // eslint-disable-line rxjs/no-exposed-subjects
|
||||
|
||||
@Input()
|
||||
set mockOrgs(orgs: Organization[]) {
|
||||
this.organizations$.next(orgs);
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: "[mockProviders]",
|
||||
})
|
||||
class MockProviderService implements Partial<ProviderService> {
|
||||
private static _providers = new BehaviorSubject<Provider[]>([]);
|
||||
|
||||
async getAll() {
|
||||
return await firstValueFrom(MockProviderService._providers);
|
||||
}
|
||||
|
||||
@Input()
|
||||
set mockProviders(providers: Provider[]) {
|
||||
MockProviderService._providers.next(providers);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "story-layout",
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
class StoryLayoutComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "story-content",
|
||||
template: ``,
|
||||
})
|
||||
class StoryContentComponent {}
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
moreFromBitwarden: "More from Bitwarden",
|
||||
secureYourInfrastructure: "Secure your infrastructure",
|
||||
protectYourFamilyOrBusiness: "Protect your family or business",
|
||||
switch: "Switch",
|
||||
skipToContent: "Skip to content",
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Web/Navigation Product Switcher",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [
|
||||
NavigationProductSwitcherComponent,
|
||||
MockOrganizationService,
|
||||
MockProviderService,
|
||||
StoryLayoutComponent,
|
||||
StoryContentComponent,
|
||||
I18nPipe,
|
||||
],
|
||||
imports: [NavigationModule, RouterModule, LayoutComponent],
|
||||
providers: [
|
||||
{ provide: OrganizationService, useClass: MockOrganizationService },
|
||||
{ provide: ProviderService, useClass: MockProviderService },
|
||||
ProductSwitcherService,
|
||||
{
|
||||
provide: I18nPipe,
|
||||
useFactory: () => ({
|
||||
transform: (key: string) => translations[key],
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService(translations);
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
importProvidersFrom(
|
||||
RouterModule.forRoot([
|
||||
{
|
||||
path: "",
|
||||
component: StoryLayoutComponent,
|
||||
children: [
|
||||
{
|
||||
path: "**",
|
||||
component: StoryContentComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
const Template: StoryFn = (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
|
||||
<bit-layout>
|
||||
<nav slot="sidebar" class="tw-flex tw-flex-col tw-h-full">
|
||||
<navigation-product-switcher></navigation-product-switcher>
|
||||
</nav>
|
||||
</bit-layout>
|
||||
`,
|
||||
});
|
||||
|
||||
export const OnlyPM = Template.bind({});
|
||||
OnlyPM.args = {
|
||||
mockOrgs: [],
|
||||
mockProviders: [],
|
||||
};
|
||||
|
||||
export const SMAvailable = Template.bind({});
|
||||
SMAvailable.args = {
|
||||
mockOrgs: [{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true }],
|
||||
mockProviders: [],
|
||||
};
|
||||
|
||||
export const SMAndACAvailable = Template.bind({});
|
||||
SMAndACAvailable.args = {
|
||||
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }],
|
||||
mockProviders: [],
|
||||
};
|
||||
|
||||
export const WithAllOptions = Template.bind({});
|
||||
WithAllOptions.args = {
|
||||
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }],
|
||||
mockProviders: [{ id: "provider-a" }],
|
||||
};
|
|
@ -1,40 +1,8 @@
|
|||
import { Component, ViewChild } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { combineLatest, concatMap } from "rxjs";
|
||||
|
||||
import {
|
||||
canAccessOrgAdmin,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { MenuComponent } from "@bitwarden/components";
|
||||
|
||||
type ProductSwitcherItem = {
|
||||
/**
|
||||
* Displayed name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Displayed icon
|
||||
*/
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* Route for items in the `bentoProducts$` section
|
||||
*/
|
||||
appRoute?: string | any[];
|
||||
|
||||
/**
|
||||
* Route for items in the `otherProducts$` section
|
||||
*/
|
||||
marketingRoute?: string | any[];
|
||||
|
||||
/**
|
||||
* Used to apply css styles to show when a button is selected
|
||||
*/
|
||||
isActive?: boolean;
|
||||
};
|
||||
import { ProductSwitcherService } from "./shared/product-switcher.service";
|
||||
|
||||
@Component({
|
||||
selector: "product-switcher-content",
|
||||
|
@ -44,99 +12,9 @@ export class ProductSwitcherContentComponent {
|
|||
@ViewChild("menu")
|
||||
menu: MenuComponent;
|
||||
|
||||
protected products$ = combineLatest([
|
||||
this.organizationService.organizations$,
|
||||
this.route.paramMap,
|
||||
]).pipe(
|
||||
concatMap(async ([orgs, paramMap]) => {
|
||||
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
|
||||
// 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);
|
||||
constructor(private productSwitcherService: ProductSwitcherService) {}
|
||||
|
||||
// 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();
|
||||
|
||||
/**
|
||||
* We can update this to the "satisfies" type upon upgrading to TypeScript 4.9
|
||||
* https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/#satisfies
|
||||
*/
|
||||
const products: Record<"pm" | "sm" | "ac" | "provider" | "orgs", ProductSwitcherItem> = {
|
||||
pm: {
|
||||
name: "Password Manager",
|
||||
icon: "bwi-lock",
|
||||
appRoute: "/vault",
|
||||
marketingRoute: "https://bitwarden.com/products/personal/",
|
||||
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: "https://bitwarden.com/products/secrets-manager/",
|
||||
isActive: this.router.url.includes("/sm/"),
|
||||
},
|
||||
ac: {
|
||||
name: "Admin Console",
|
||||
icon: "bwi-business",
|
||||
appRoute: ["/organizations", acOrg?.id],
|
||||
marketingRoute: "https://bitwarden.com/products/business/",
|
||||
isActive: this.router.url.includes("/organizations/"),
|
||||
},
|
||||
provider: {
|
||||
name: "Provider Portal",
|
||||
icon: "bwi-provider",
|
||||
appRoute: ["/providers", providers[0]?.id],
|
||||
isActive: this.router.url.includes("/providers/"),
|
||||
},
|
||||
orgs: {
|
||||
name: "Organizations",
|
||||
icon: "bwi-business",
|
||||
marketingRoute: "https://bitwarden.com/products/business/",
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
other.push(products.orgs);
|
||||
}
|
||||
|
||||
if (providers.length > 0) {
|
||||
bento.push(products.provider);
|
||||
}
|
||||
|
||||
return {
|
||||
bento,
|
||||
other,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private organizationService: OrganizationService,
|
||||
private providerService: ProviderService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
) {}
|
||||
get products$() {
|
||||
return this.productSwitcherService.products$;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,16 +3,22 @@ import { NgModule } from "@angular/core";
|
|||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component";
|
||||
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
|
||||
import { ProductSwitcherComponent } from "./product-switcher.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, A11yModule, RouterModule],
|
||||
declarations: [ProductSwitcherComponent, ProductSwitcherContentComponent],
|
||||
exports: [ProductSwitcherComponent],
|
||||
imports: [SharedModule, A11yModule, RouterModule, NavigationModule],
|
||||
declarations: [
|
||||
ProductSwitcherComponent,
|
||||
ProductSwitcherContentComponent,
|
||||
NavigationProductSwitcherComponent,
|
||||
],
|
||||
exports: [ProductSwitcherComponent, NavigationProductSwitcherComponent],
|
||||
providers: [I18nPipe],
|
||||
})
|
||||
export class ProductSwitcherModule {}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.servi
|
|||
|
||||
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
|
||||
import { ProductSwitcherComponent } from "./product-switcher.component";
|
||||
import { ProductSwitcherService } from "./shared/product-switcher.service";
|
||||
|
||||
@Directive({
|
||||
selector: "[mockOrgs]",
|
||||
|
@ -74,12 +75,15 @@ export default {
|
|||
MockOrganizationService,
|
||||
{ provide: ProviderService, useClass: MockProviderService },
|
||||
MockProviderService,
|
||||
ProductSwitcherService,
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
moreFromBitwarden: "More from Bitwarden",
|
||||
switchProducts: "Switch Products",
|
||||
secureYourInfrastructure: "Secure your infrastructure",
|
||||
protectYourFamilyOrBusiness: "Protect your family or business",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,186 @@
|
|||
import { TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
|
||||
import { ProductSwitcherService } from "./product-switcher.service";
|
||||
|
||||
describe("ProductSwitcherService", () => {
|
||||
let service: ProductSwitcherService;
|
||||
let router: MockProxy<Router>;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let providerService: MockProxy<ProviderService>;
|
||||
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
|
||||
|
||||
const setRouterURL = (path: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
// `url` is a read-only property in practice but is mocked here for testing purposes
|
||||
router.url = path;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
router = mock<Router>();
|
||||
organizationService = mock<OrganizationService>();
|
||||
providerService = mock<ProviderService>();
|
||||
|
||||
setRouterURL("/");
|
||||
|
||||
organizationService.organizations$ = of([{}] as Organization[]);
|
||||
providerService.getAll.mockResolvedValue([] as Provider[]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: OrganizationService, useValue: organizationService },
|
||||
{ provide: ProviderService, useValue: providerService },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(activeRouteParams),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nPipe,
|
||||
useValue: {
|
||||
transform: (key: string) => key,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("product separation", () => {
|
||||
describe("Password Manager", () => {
|
||||
it("is always included", async () => {
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
expect(products.bento.find((p) => p.name === "Password Manager")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Secret Manager", () => {
|
||||
it("is included in other when there are no organizations with SM", async () => {
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
expect(products.other.find((p) => p.name === "Secrets Manager")).toBeDefined();
|
||||
});
|
||||
|
||||
it("is included in bento when there is an organization with SM", async () => {
|
||||
organizationService.organizations$ = of([
|
||||
{ id: "1234", canAccessSecretsManager: true, enabled: true },
|
||||
] as Organization[]);
|
||||
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
expect(products.bento.find((p) => p.name === "Secrets Manager")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin/Organizations", () => {
|
||||
it("includes Organizations in other when there are organizations", async () => {
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
expect(products.other.find((p) => p.name === "Organizations")).toBeDefined();
|
||||
expect(products.bento.find((p) => p.name === "Admin Console")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes Admin Console in bento when a user has access to it", async () => {
|
||||
organizationService.organizations$ = of([{ id: "1234", isOwner: true }] as Organization[]);
|
||||
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
expect(products.bento.find((p) => p.name === "Admin Console")).toBeDefined();
|
||||
expect(products.other.find((p) => p.name === "Organizations")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Provider Portal", () => {
|
||||
it("is not included when there are no providers", async () => {
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
expect(products.bento.find((p) => p.name === "Provider Portal")).toBeUndefined();
|
||||
expect(products.other.find((p) => p.name === "Provider Portal")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is included when there are providers", async () => {
|
||||
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]);
|
||||
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
expect(products.bento.find((p) => p.name === "Provider Portal")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("active product", () => {
|
||||
it("marks Password Manager as active", async () => {
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
const { isActive } = products.bento.find((p) => p.name === "Password Manager");
|
||||
|
||||
expect(isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("marks Secret Manager as active", async () => {
|
||||
setRouterURL("/sm/");
|
||||
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
const { isActive } = products.other.find((p) => p.name === "Secrets Manager");
|
||||
|
||||
expect(isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("marks Admin Console as active", async () => {
|
||||
organizationService.organizations$ = of([{ id: "1234", isOwner: true }] as Organization[]);
|
||||
activeRouteParams = convertToParamMap({ organizationId: "1" });
|
||||
setRouterURL("/organizations/");
|
||||
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
const { isActive } = products.bento.find((p) => p.name === "Admin Console");
|
||||
|
||||
expect(isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("marks Provider Portal as active", async () => {
|
||||
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]);
|
||||
setRouterURL("/providers/");
|
||||
|
||||
service = TestBed.inject(ProductSwitcherService);
|
||||
|
||||
const products = await firstValueFrom(service.products$);
|
||||
|
||||
const { isActive } = products.bento.find((p) => p.name === "Provider Portal");
|
||||
|
||||
expect(isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,158 @@
|
|||
import { Injectable } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { combineLatest, concatMap, Observable } from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import {
|
||||
canAccessOrgAdmin,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
|
||||
export type ProductSwitcherItem = {
|
||||
/**
|
||||
* Displayed name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Displayed icon
|
||||
*/
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* Route for items in the `bentoProducts$` section
|
||||
*/
|
||||
appRoute?: string | any[];
|
||||
|
||||
/**
|
||||
* Route for items in the `otherProducts$` section
|
||||
*/
|
||||
marketingRoute?: string | any[];
|
||||
|
||||
/**
|
||||
* Used to apply css styles to show when a button is selected
|
||||
*/
|
||||
isActive?: boolean;
|
||||
|
||||
/**
|
||||
* A product switcher item can be shown in the left navigation menu
|
||||
* with different content than the main product switcher.
|
||||
*/
|
||||
navigationUIDetails?: {
|
||||
/** Alternative navigation menu name */
|
||||
name?: string;
|
||||
/** Supporting text that is shown in the "more from bitwarden" section */
|
||||
supportingText?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class ProductSwitcherService {
|
||||
constructor(
|
||||
private organizationService: OrganizationService,
|
||||
private providerService: ProviderService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private i18n: I18nPipe,
|
||||
) {}
|
||||
|
||||
products$: Observable<{
|
||||
bento: ProductSwitcherItem[];
|
||||
other: ProductSwitcherItem[];
|
||||
}> = combineLatest([this.organizationService.organizations$, this.route.paramMap]).pipe(
|
||||
concatMap(async ([orgs, paramMap]) => {
|
||||
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
|
||||
|
||||
// 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();
|
||||
|
||||
/**
|
||||
* We can update this to the "satisfies" type upon upgrading to TypeScript 4.9
|
||||
* https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/#satisfies
|
||||
*/
|
||||
const products: Record<"pm" | "sm" | "ac" | "provider" | "orgs", ProductSwitcherItem> = {
|
||||
pm: {
|
||||
name: "Password Manager",
|
||||
icon: "bwi-lock",
|
||||
appRoute: "/vault",
|
||||
marketingRoute: "https://bitwarden.com/products/personal/",
|
||||
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: "https://bitwarden.com/products/secrets-manager/",
|
||||
isActive: this.router.url.includes("/sm/"),
|
||||
navigationUIDetails: {
|
||||
supportingText: this.i18n.transform("secureYourInfrastructure"),
|
||||
},
|
||||
},
|
||||
ac: {
|
||||
name: "Admin Console",
|
||||
icon: "bwi-business",
|
||||
appRoute: ["/organizations", acOrg?.id],
|
||||
marketingRoute: "https://bitwarden.com/products/business/",
|
||||
isActive: this.router.url.includes("/organizations/"),
|
||||
},
|
||||
provider: {
|
||||
name: "Provider Portal",
|
||||
icon: "bwi-provider",
|
||||
appRoute: ["/providers", providers[0]?.id],
|
||||
isActive: this.router.url.includes("/providers/"),
|
||||
},
|
||||
orgs: {
|
||||
name: "Organizations",
|
||||
icon: "bwi-business",
|
||||
marketingRoute: "https://bitwarden.com/products/business/",
|
||||
navigationUIDetails: {
|
||||
name: "Share your passwords",
|
||||
supportingText: this.i18n.transform("protectYourFamilyOrBusiness"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
other.push(products.orgs);
|
||||
}
|
||||
|
||||
if (providers.length > 0) {
|
||||
bento.push(products.provider);
|
||||
}
|
||||
|
||||
return {
|
||||
bento,
|
||||
other,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ import { NavigationModule } from "@bitwarden/components";
|
|||
text="Toggle Width"
|
||||
icon="bwi-bug"
|
||||
*ngIf="isDev"
|
||||
class="tw-absolute tw-bottom-0 tw-w-full"
|
||||
class="tw-w-full"
|
||||
(click)="toggleWidth()"
|
||||
></bit-nav-item>`,
|
||||
standalone: true,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<bit-layout>
|
||||
<nav slot="sidebar">
|
||||
<nav slot="sidebar" class="tw-flex tw-flex-col tw-h-full">
|
||||
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'passwordManager' | i18n">
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
</a>
|
||||
|
@ -33,6 +33,8 @@
|
|||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
|
||||
<navigation-product-switcher></navigation-product-switcher>
|
||||
|
||||
<app-toggle-width></app-toggle-width>
|
||||
</nav>
|
||||
<app-payment-method-warnings
|
||||
|
|
|
@ -16,6 +16,7 @@ import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/compon
|
|||
import { PaymentMethodWarningsModule } from "../billing/shared";
|
||||
|
||||
import { PasswordManagerLogo } from "./password-manager-logo";
|
||||
import { ProductSwitcherModule } from "./product-switcher/product-switcher.module";
|
||||
import { ToggleWidthComponent } from "./toggle-width.component";
|
||||
|
||||
@Component({
|
||||
|
@ -31,6 +32,7 @@ import { ToggleWidthComponent } from "./toggle-width.component";
|
|||
NavigationModule,
|
||||
PaymentMethodWarningsModule,
|
||||
ToggleWidthComponent,
|
||||
ProductSwitcherModule,
|
||||
],
|
||||
})
|
||||
export class UserLayoutComponent implements OnInit {
|
||||
|
|
|
@ -8049,5 +8049,14 @@
|
|||
},
|
||||
"collectionItemSelect": {
|
||||
"message": "Select collection item"
|
||||
},
|
||||
"secureYourInfrastructure": {
|
||||
"message": "Secure your infrastructure"
|
||||
},
|
||||
"protectYourFamilyOrBusiness": {
|
||||
"message": "Protect your family or business"
|
||||
},
|
||||
"switch":{
|
||||
"message": "Switch"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<bit-layout variant="secondary">
|
||||
<nav slot="sidebar" *ngIf="provider">
|
||||
<nav slot="sidebar" *ngIf="provider" class="tw-flex tw-flex-col tw-h-full">
|
||||
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'providerPortal' | i18n">
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
</a>
|
||||
|
@ -33,6 +33,7 @@
|
|||
*ngIf="showSettingsTab"
|
||||
></bit-nav-item>
|
||||
|
||||
<navigation-product-switcher></navigation-product-switcher>
|
||||
<app-toggle-width></app-toggle-width>
|
||||
</nav>
|
||||
<app-payment-method-warnings
|
||||
|
|
|
@ -11,6 +11,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
|||
import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
|
||||
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
|
||||
import { ProductSwitcherModule } from "@bitwarden/web-vault/app/layouts/product-switcher/product-switcher.module";
|
||||
import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component";
|
||||
|
||||
@Component({
|
||||
|
@ -26,6 +27,7 @@ import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-wi
|
|||
NavigationModule,
|
||||
PaymentMethodWarningsModule,
|
||||
ToggleWidthComponent,
|
||||
ProductSwitcherModule,
|
||||
],
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
|
|
|
@ -2,6 +2,7 @@ import { NgModule } from "@angular/core";
|
|||
|
||||
import { LayoutComponent as BitLayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
import { OrgSwitcherComponent } from "@bitwarden/web-vault/app/layouts/org-switcher/org-switcher.component";
|
||||
import { ProductSwitcherModule } from "@bitwarden/web-vault/app/layouts/product-switcher/product-switcher.module";
|
||||
import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
|
||||
|
||||
|
@ -15,6 +16,7 @@ import { NavigationComponent } from "./navigation.component";
|
|||
BitLayoutComponent,
|
||||
OrgSwitcherComponent,
|
||||
ToggleWidthComponent,
|
||||
ProductSwitcherModule,
|
||||
],
|
||||
declarations: [LayoutComponent, NavigationComponent],
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<nav>
|
||||
<nav class="tw-flex tw-flex-col tw-h-full">
|
||||
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block">
|
||||
<bit-icon [icon]="logo"></bit-icon>
|
||||
</a>
|
||||
|
@ -48,5 +48,7 @@
|
|||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
|
||||
<navigation-product-switcher></navigation-product-switcher>
|
||||
|
||||
<app-toggle-width></app-toggle-width>
|
||||
</nav>
|
||||
|
|
|
@ -86,6 +86,9 @@ module.exports = {
|
|||
...theme("colors"),
|
||||
}),
|
||||
extend: {
|
||||
fontSize: {
|
||||
xxs: ["0.625rem", "0.875rem"],
|
||||
},
|
||||
width: {
|
||||
"50vw": "50vw",
|
||||
"75vw": "75vw",
|
||||
|
|
Loading…
Reference in New Issue