[PM-7231] Product Switcher within navigation sidebar (#8810)

* refactor: move logic for products into a service

- This is in preparation for having having the navigation menu show products based off of the same logic.

* add extra small font size to tailwind config

* remove absolute positioning from toggle width component
- it now sits beneath the product switcher

* update product switcher to have UI details that are only shown in the navigation pane

* add navigation oriented product switcher

* integrate navigation product switcher into secrets manager

* integrate navigation product switcher into provider console

* integrate navigation product switcher into user layout

* integrate navigation product switcher into organizations

* add translation for "switch"

* hide active styles from navigation product switcher

* update storybook for product switcher stories

* remove unneeded full width style

* use protected readonly variable instead of getter

* migrate stories to CSF3

* remove double subscription to `moreProducts$`

* only use wrapping div in navigation switcher story

- less vertical space is taken up

* update to satisfies

* refactor `navigationUI` to `otherProductOverrides`

* move observables to protected readonly

* apply margin-top via class on the host component

* remove switch text from the navigation product switcher

* Allow for the active navigation switcher to be shown

* remove xxs font style

* remove unneeded module

* remove switch from stories

* remove defensive nullish coalescing

* remove merge leftovers

* Defect PM-7899 - show organizations product at the top of the other products list

* Defect PM-7951 use attr.icon to keep the icon as an attribute after prod mode is enabled

* Defect PM-7948 update path based on the current org

* force active styles for navigation items (#9128)

* add horizontal margin to icon

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Nick Krantz 2024-05-16 08:18:58 -05:00 committed by GitHub
parent ff19514c27
commit 07076ebf9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 932 additions and 168 deletions

View File

@ -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 class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>

View File

@ -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 {

View File

@ -0,0 +1,35 @@
<div class="tw-mt-auto">
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts$ | async"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
>
</bit-nav-item>
<ng-container *ngIf="moreProducts$ | async as moreProducts">
<section
*ngIf="moreProducts.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"
[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 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div *ngIf="more.otherProductOverrides?.supportingText" class="tw-text-xs tw-font-normal">
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</a>
</section>
</ng-container>
</div>

View File

@ -0,0 +1,194 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
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 `otherProductOverrides` when available", () => {
mockProducts$.next({
bento: [],
other: [
{
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
otherProductOverrides: { 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/",
otherProductOverrides: { name: "Alternate name", supportingText: "Supporting Text" },
},
],
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("a").textContent.trim().replace(/\s+/g, " ")).toBe(
"Alternate name Supporting Text",
);
});
it("shows Organizations first in the other products list", () => {
mockProducts$.next({
bento: [],
other: [
{ name: "AA Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
{ name: "Test Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
{ name: "Organizations", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
],
});
fixture.detectChanges();
const links = fixture.nativeElement.querySelectorAll("a");
expect(links.length).toBe(3);
expect(links[0].textContent).toContain("Organizations");
expect(links[1].textContent).toContain("AA Product");
expect(links[2].textContent).toContain("Test Product");
});
it('shows the nav item as active when "isActive" is true', () => {
mockProducts$.next({
bento: [
{
name: "Organizations",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
isActive: true,
},
],
other: [],
});
fixture.detectChanges();
const navItem = fixture.debugElement.query(By.directive(NavItemComponent));
expect(navItem.componentInstance.forceActiveStyles).toBe(true);
});
});
describe("available products", () => {
it("shows all 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(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");
});
});

View File

@ -0,0 +1,24 @@
import { Component } 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 {
constructor(private productSwitcherService: ProductSwitcherService) {}
protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(map((products) => products.bento ?? []));
protected readonly moreProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(
map((products) => products.other ?? []),
// Ensure that organizations is displayed first in the other products list
// This differs from the order in `ProductSwitcherContentComponent` but matches the intent
// from product & design
map((products) => products.sort((product) => (product.name === "Organizations" ? -1 : 1))),
);
}

View File

@ -0,0 +1,171 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, StoryObj } 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",
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<NavigationProductSwitcherComponent>;
type Story = StoryObj<
NavigationProductSwitcherComponent & MockProviderService & MockOrganizationService
>;
const Template: Story = {
render: (args) => ({
props: args,
template: `
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
<div class="tw-bg-background-alt3 tw-w-60">
<navigation-product-switcher></navigation-product-switcher>
</div>
`,
}),
};
export const OnlyPM: Story = {
...Template,
args: {
mockOrgs: [],
mockProviders: [],
},
};
export const SMAvailable: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const SMAndACAvailable: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const WithAllOptions: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [{ id: "provider-a" }] as Provider[],
},
};

View File

@ -1,41 +1,8 @@
import { Component, ViewChild } from "@angular/core";
import { ActivatedRoute, ParamMap, Router } from "@angular/router";
import { combineLatest, concatMap, map } 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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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",
@ -45,106 +12,7 @@ export class ProductSwitcherContentComponent {
@ViewChild("menu")
menu: MenuComponent;
protected products$ = combineLatest([
this.organizationService.organizations$,
this.route.paramMap,
]).pipe(
map(([orgs, paramMap]): [Organization[], ParamMap] => {
return [
// Sort orgs by name to match the order within the sidebar
orgs.sort((a, b) => a.name.localeCompare(b.name)),
paramMap,
];
}),
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-user-monitor",
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,
) {}
protected readonly products$ = this.productSwitcherService.products$;
}

View File

@ -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 {}

View File

@ -1,6 +1,6 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, Story } from "@storybook/angular";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@ -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",
});
},
},
@ -120,11 +124,14 @@ export default {
],
}),
],
} as Meta;
} as Meta<ProductSwitcherComponent>;
const Template: Story = (args) => ({
props: args,
template: `
type Story = StoryObj<ProductSwitcherComponent & MockProviderService & MockOrganizationService>;
const Template: Story = {
render: (args) => ({
props: args,
template: `
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
<div class="tw-flex tw-gap-[200px]">
<div>
@ -142,28 +149,42 @@ const Template: Story = (args) => ({
</div>
</div>
`,
});
export const OnlyPM = Template.bind({});
OnlyPM.args = {
mockOrgs: [],
mockProviders: [],
}),
};
export const OnlyPM: Story = {
...Template,
args: {
mockOrgs: [],
mockProviders: [],
},
};
export const WithSM = Template.bind({});
WithSM.args = {
mockOrgs: [{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true }],
mockProviders: [],
export const WithSM: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const WithSMAndAC = Template.bind({});
WithSMAndAC.args = {
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }],
mockProviders: [],
export const WithSMAndAC: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const WithAllOptions = Template.bind({});
WithAllOptions.args = {
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }],
mockProviders: [{ id: "provider-a" }],
export const WithAllOptions: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [{ id: "provider-a" }] as Provider[],
},
};

View File

@ -0,0 +1,216 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { Observable, 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: { url: string; events: Observable<unknown> };
let organizationService: MockProxy<OrganizationService>;
let providerService: MockProxy<ProviderService>;
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
beforeEach(() => {
router = mock<Router>();
organizationService = mock<OrganizationService>();
providerService = mock<ProviderService>();
router.url = "/";
router.events = of({});
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),
url: of([]),
},
},
{
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 () => {
router.url = "/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" });
router.url = "/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[]);
router.url = "/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);
});
});
describe("current org path", () => {
it("updates secrets manager path when the org id is found in the path", async () => {
router.url = "/sm/4243";
organizationService.organizations$ = of([
{ id: "23443234", canAccessSecretsManager: true, enabled: true, name: "Org 2" },
{ id: "4243", canAccessSecretsManager: true, enabled: true, name: "Org 32" },
] as Organization[]);
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
const { appRoute } = products.bento.find((p) => p.name === "Secrets Manager");
expect(appRoute).toEqual(["/sm", "4243"]);
});
});
it("updates admin console path when the org id is found in the path", async () => {
router.url = "/organizations/111-22-33";
organizationService.organizations$ = of([
{ id: "111-22-33", isOwner: true, name: "Test Org" },
{ id: "4243", isOwner: true, name: "My Org" },
] as Organization[]);
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
const { appRoute } = products.bento.find((p) => p.name === "Admin Console");
expect(appRoute).toEqual(["/organizations", "111-22-33"]);
});
});

View File

@ -0,0 +1,189 @@
import { Injectable } from "@angular/core";
import {
ActivatedRoute,
Event,
NavigationEnd,
NavigationStart,
ParamMap,
Router,
} from "@angular/router";
import { combineLatest, concatMap, filter, map, Observable, startWith } 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";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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.
* When shown under the "other" section the content can be overridden.
*/
otherProductOverrides?: {
/** Alternative navigation menu name */
name?: string;
/** Supporting text that is shown when the product is rendered in the "other" 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,
this.router.events.pipe(
// Product paths need to be updated when routes change, but the router event isn't actually needed
startWith(null), // Start with a null event to trigger the initial combineLatest
filter((e) => e instanceof NavigationEnd || e instanceof NavigationStart || e === null),
),
]).pipe(
map(([orgs, ...rest]): [Organization[], ParamMap, Event | null] => {
return [
// Sort orgs by name to match the order within the sidebar
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;
if (["/sm/", "/organizations/"].some((path) => this.router.url.includes(path))) {
// 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 (organizationIdViaPath && !routeOrg) {
routeOrg = orgs.find((o) => o.id === organizationIdViaPath);
}
// 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 products = {
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/"),
otherProductOverrides: {
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/",
otherProductOverrides: {
name: "Share your passwords",
supportingText: this.i18n.transform("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 {
other.push(products.orgs);
}
if (providers.length > 0) {
bento.push(products.provider);
}
return {
bento,
other,
};
}),
);
}

View File

@ -10,7 +10,6 @@ import { NavigationModule } from "@bitwarden/components";
text="Toggle Width"
icon="bwi-bug"
*ngIf="isDev"
class="tw-absolute tw-bottom-0 tw-w-full"
(click)="toggleWidth()"
></bit-nav-item>`,
standalone: true,

View File

@ -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 class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>
<app-payment-method-warnings

View File

@ -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 {

View File

@ -8216,5 +8216,11 @@
"example": "2"
}
}
},
"secureYourInfrastructure": {
"message": "Secure your infrastructure"
},
"protectYourFamilyOrBusiness": {
"message": "Protect your family or business"
}
}

View File

@ -1,5 +1,5 @@
<bit-layout variant="secondary">
<nav slot="sidebar" *ngIf="provider$ | async as provider">
<nav slot="sidebar" *ngIf="provider$ | async as 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>
@ -40,6 +40,9 @@
route="settings"
*ngIf="showSettingsTab(provider)"
></bit-nav-item>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>
<app-payment-method-warnings

View File

@ -13,6 +13,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({
@ -28,6 +29,7 @@ import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-wi
NavigationModule,
PaymentMethodWarningsModule,
ToggleWidthComponent,
ProductSwitcherModule,
],
})
export class ProvidersLayoutComponent implements OnInit, OnDestroy {

View File

@ -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],
})

View File

@ -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 class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>

View File

@ -1,4 +1,4 @@
import { Component, HostListener, Optional } from "@angular/core";
import { Component, HostListener, Input, Optional } from "@angular/core";
import { BehaviorSubject, map } from "rxjs";
import { NavBaseComponent } from "./nav-base.component";
@ -10,6 +10,9 @@ import { NavGroupComponent } from "./nav-group.component";
providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }],
})
export class NavItemComponent extends NavBaseComponent {
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
@Input() forceActiveStyles? = false;
/**
* Is `true` if `to` matches the current route
*/
@ -21,7 +24,7 @@ export class NavItemComponent extends NavBaseComponent {
}
}
protected get showActiveStyles() {
return this._isActive && !this.hideActiveStyles;
return this.forceActiveStyles || (this._isActive && !this.hideActiveStyles);
}
/**

View File

@ -101,3 +101,14 @@ export const MultipleItemsWithDivider: Story = {
`,
}),
};
export const ForceActiveStyles: Story = {
render: (args: NavItemComponent) => ({
props: args,
template: `
<bit-nav-item text="First Nav" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Active Nav" icon="bwi-collection" [forceActiveStyles]="true"></bit-nav-item>
<bit-nav-item text="Third Nav" icon="bwi-collection"></bit-nav-item>
`,
}),
};