1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

Merge branch 'main' of github.com:bitwarden/clients

This commit is contained in:
gbubemismith
2024-05-16 09:21:29 -04:00
50 changed files with 1660 additions and 529 deletions

View File

@@ -230,6 +230,8 @@ import { BrowserPlatformUtilsService } from "../platform/services/platform-utils
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider"; import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider";
import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service"; import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service";
import { ForegroundSyncService } from "../platform/sync/foreground-sync.service";
import { SyncServiceListener } from "../platform/sync/sync-service.listener";
import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
import FilelessImporterBackground from "../tools/background/fileless-importer.background"; import FilelessImporterBackground from "../tools/background/fileless-importer.background";
@@ -339,6 +341,7 @@ export default class MainBackground {
scriptInjectorService: BrowserScriptInjectorService; scriptInjectorService: BrowserScriptInjectorService;
kdfConfigService: kdfConfigServiceAbstraction; kdfConfigService: kdfConfigServiceAbstraction;
offscreenDocumentService: OffscreenDocumentService; offscreenDocumentService: OffscreenDocumentService;
syncServiceListener: SyncServiceListener;
onUpdatedRan: boolean; onUpdatedRan: boolean;
onReplacedRan: boolean; onReplacedRan: boolean;
@@ -377,7 +380,8 @@ export default class MainBackground {
const logoutCallback = async (expired: boolean, userId?: UserId) => const logoutCallback = async (expired: boolean, userId?: UserId) =>
await this.logout(expired, userId); await this.logout(expired, userId);
this.logService = new ConsoleLogService(false); const isDev = process.env.ENV === "development";
this.logService = new ConsoleLogService(isDev);
this.cryptoFunctionService = new WebCryptoFunctionService(self); this.cryptoFunctionService = new WebCryptoFunctionService(self);
this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService);
this.storageService = new BrowserLocalStorageService(); this.storageService = new BrowserLocalStorageService();
@@ -396,7 +400,7 @@ export default class MainBackground {
), ),
); );
this.offscreenDocumentService = new DefaultOffscreenDocumentService(); this.offscreenDocumentService = new DefaultOffscreenDocumentService(this.logService);
this.platformUtilsService = new BackgroundPlatformUtilsService( this.platformUtilsService = new BackgroundPlatformUtilsService(
this.messagingService, this.messagingService,
@@ -792,32 +796,52 @@ export default class MainBackground {
this.providerService = new ProviderService(this.stateProvider); this.providerService = new ProviderService(this.stateProvider);
this.syncService = new SyncService( if (this.popupOnlyContext) {
this.masterPasswordService, this.syncService = new ForegroundSyncService(
this.accountService, this.stateService,
this.apiService, this.folderService,
this.domainSettingsService, this.folderApiService,
this.folderService, this.messagingService,
this.cipherService, this.logService,
this.cryptoService, this.cipherService,
this.collectionService, this.collectionService,
this.messagingService, this.apiService,
this.policyService, this.accountService,
this.sendService, this.authService,
this.logService, this.sendService,
this.keyConnectorService, this.sendApiService,
this.stateService, messageListener,
this.providerService, );
this.folderApiService, } else {
this.organizationService, this.syncService = new SyncService(
this.sendApiService, this.masterPasswordService,
this.userDecryptionOptionsService, this.accountService,
this.avatarService, this.apiService,
logoutCallback, this.domainSettingsService,
this.billingAccountProfileStateService, this.folderService,
this.tokenService, this.cipherService,
this.authService, this.cryptoService,
); this.collectionService,
this.messagingService,
this.policyService,
this.sendService,
this.logService,
this.keyConnectorService,
this.stateService,
this.providerService,
this.folderApiService,
this.organizationService,
this.sendApiService,
this.userDecryptionOptionsService,
this.avatarService,
logoutCallback,
this.billingAccountProfileStateService,
this.tokenService,
this.authService,
);
this.syncServiceListener = new SyncServiceListener(this.syncService, messageListener);
}
this.eventUploadService = new EventUploadService( this.eventUploadService = new EventUploadService(
this.apiService, this.apiService,
this.stateProvider, this.stateProvider,
@@ -1141,6 +1165,7 @@ export default class MainBackground {
this.contextMenusBackground?.init(); this.contextMenusBackground?.init();
await this.idleBackground.init(); await this.idleBackground.init();
this.webRequestBackground?.startListening(); this.webRequestBackground?.startListening();
this.syncServiceListener?.startListening();
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
setTimeout(async () => { setTimeout(async () => {

View File

@@ -1,3 +1,7 @@
import { mock } from "jest-mock-extended";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DefaultOffscreenDocumentService } from "./offscreen-document.service"; import { DefaultOffscreenDocumentService } from "./offscreen-document.service";
class TestCase { class TestCase {
@@ -21,6 +25,7 @@ describe.each([
new TestCase("synchronous callback", () => 42), new TestCase("synchronous callback", () => 42),
new TestCase("asynchronous callback", () => Promise.resolve(42)), new TestCase("asynchronous callback", () => Promise.resolve(42)),
])("DefaultOffscreenDocumentService %s", (testCase) => { ])("DefaultOffscreenDocumentService %s", (testCase) => {
const logService = mock<LogService>();
let sut: DefaultOffscreenDocumentService; let sut: DefaultOffscreenDocumentService;
const reasons = [chrome.offscreen.Reason.TESTING]; const reasons = [chrome.offscreen.Reason.TESTING];
const justification = "justification is testing"; const justification = "justification is testing";
@@ -37,7 +42,7 @@ describe.each([
callback = testCase.callback; callback = testCase.callback;
chrome.offscreen = api; chrome.offscreen = api;
sut = new DefaultOffscreenDocumentService(); sut = new DefaultOffscreenDocumentService(logService);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -1,7 +1,9 @@
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
export class DefaultOffscreenDocumentService implements DefaultOffscreenDocumentService { export class DefaultOffscreenDocumentService implements DefaultOffscreenDocumentService {
private workerCount = 0; private workerCount = 0;
constructor() {} constructor(private logService: LogService) {}
async withDocument<T>( async withDocument<T>(
reasons: chrome.offscreen.Reason[], reasons: chrome.offscreen.Reason[],
@@ -24,11 +26,21 @@ export class DefaultOffscreenDocumentService implements DefaultOffscreenDocument
} }
private async create(reasons: chrome.offscreen.Reason[], justification: string): Promise<void> { private async create(reasons: chrome.offscreen.Reason[], justification: string): Promise<void> {
await chrome.offscreen.createDocument({ try {
url: "offscreen-document/index.html", await chrome.offscreen.createDocument({
reasons, url: "offscreen-document/index.html",
justification, reasons,
}); justification,
});
} catch (e) {
// gobble multiple offscreen document creation errors
// TODO: remove this when the offscreen document service is fixed PM-8014
if (e.message === "Only a single offscreen document may be created.") {
this.logService.info("Ignoring offscreen document creation error.");
return;
}
throw e;
}
} }
private async close(): Promise<void> { private async close(): Promise<void> {

View File

@@ -0,0 +1,11 @@
<div class="tw-flex tw-justify-between tw-items-end tw-gap-1 tw-px-1 tw-pb-1">
<div>
<h2 bitTypography="h6" noMargin class="tw-mb-0 tw-text-headers">
{{ title }}
</h2>
<ng-content select="[slot=title-suffix]"></ng-content>
</div>
<div class="tw-text-muted has-[button]:-tw-mb-1">
<ng-content select="[slot=end]"></ng-content>
</div>
</div>

View File

@@ -0,0 +1,13 @@
import { Component, Input } from "@angular/core";
import { TypographyModule } from "@bitwarden/components";
@Component({
standalone: true,
selector: "popup-section-header",
templateUrl: "./popup-section-header.component.html",
imports: [TypographyModule],
})
export class PopupSectionHeaderComponent {
@Input() title: string;
}

View File

@@ -0,0 +1,90 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import {
CardComponent,
IconButtonModule,
SectionComponent,
TypographyModule,
} from "@bitwarden/components";
import { PopupSectionHeaderComponent } from "./popup-section-header.component";
export default {
title: "Browser/Popup Section Header",
component: PopupSectionHeaderComponent,
args: {
title: "Title",
},
decorators: [
moduleMetadata({
imports: [SectionComponent, CardComponent, TypographyModule, IconButtonModule],
}),
],
} as Meta<PopupSectionHeaderComponent>;
type Story = StoryObj<PopupSectionHeaderComponent>;
export const OnlyTitle: Story = {
render: (args) => ({
props: args,
template: `
<popup-section-header [title]="title"></popup-section-header>
`,
}),
args: {
title: "Only Title",
},
};
export const TrailingText: Story = {
render: (args) => ({
props: args,
template: `
<popup-section-header [title]="title">
<span bitTypography="body2" slot="end">13</span>
</popup-section-header>
`,
}),
args: {
title: "Trailing Text",
},
};
export const TailingIcon: Story = {
render: (args) => ({
props: args,
template: `
<popup-section-header [title]="title">
<button bitIconButton="bwi-star" size="small" slot="end"></button>
</popup-section-header>
`,
}),
args: {
title: "Trailing Icon",
},
};
export const WithSections: Story = {
render: () => ({
template: `
<div class="tw-bg-background-alt tw-p-2">
<bit-section>
<popup-section-header title="Section 1">
<button bitIconButton="bwi-star" size="small" slot="end"></button>
</popup-section-header>
<bit-card>
<h3 bitTypography="h3">Card 1 Content</h3>
</bit-card>
</bit-section>
<bit-section>
<popup-section-header title="Section 2">
<button bitIconButton="bwi-star" size="small" slot="end"></button>
</popup-section-header>
<bit-card>
<h3 bitTypography="h3">Card 2 Content</h3>
</bit-card>
</bit-section>
</div>
`,
}),
};

View File

@@ -0,0 +1,79 @@
import { firstValueFrom, timeout } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import {
CommandDefinition,
MessageListener,
MessageSender,
} from "@bitwarden/common/platform/messaging";
import { CoreSyncService } from "@bitwarden/common/platform/sync/internal";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
const SYNC_COMPLETED = new CommandDefinition<{ successfully: boolean }>("syncCompleted");
export const DO_FULL_SYNC = new CommandDefinition<{
forceSync: boolean;
allowThrowOnError: boolean;
}>("doFullSync");
export class ForegroundSyncService extends CoreSyncService {
constructor(
stateService: StateService,
folderService: InternalFolderService,
folderApiService: FolderApiServiceAbstraction,
messageSender: MessageSender,
logService: LogService,
cipherService: CipherService,
collectionService: CollectionService,
apiService: ApiService,
accountService: AccountService,
authService: AuthService,
sendService: InternalSendService,
sendApiService: SendApiService,
private readonly messageListener: MessageListener,
) {
super(
stateService,
folderService,
folderApiService,
messageSender,
logService,
cipherService,
collectionService,
apiService,
accountService,
authService,
sendService,
sendApiService,
);
}
async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise<boolean> {
this.syncInProgress = true;
try {
const syncCompletedPromise = firstValueFrom(
this.messageListener.messages$(SYNC_COMPLETED).pipe(
timeout({
first: 10_000,
with: () => {
throw new Error("Timeout while doing a fullSync call.");
},
}),
),
);
this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError });
const result = await syncCompletedPromise;
return result.successfully;
} finally {
this.syncInProgress = false;
}
}
}

View File

@@ -0,0 +1,25 @@
import { Subscription, concatMap, filter } from "rxjs";
import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/messaging";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DO_FULL_SYNC } from "./foreground-sync.service";
export class SyncServiceListener {
constructor(
private readonly syncService: SyncService,
private readonly messageListener: MessageListener,
) {}
startListening(): Subscription {
return this.messageListener
.messages$(DO_FULL_SYNC)
.pipe(
filter((message) => isExternalMessage(message)),
concatMap(async ({ forceSync, allowThrowOnError }) => {
await this.syncService.fullSync(forceSync, allowThrowOnError);
}),
)
.subscribe();
}
}

View File

@@ -47,6 +47,7 @@ import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.comp
import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../platform/popup/layout/popup-page.component";
import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component"; import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component";
import { PopupSectionHeaderComponent } from "../platform/popup/popup-section-header/popup-section-header.component";
import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component"; import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component";
import { GeneratorComponent } from "../tools/popup/generator/generator.component"; import { GeneratorComponent } from "../tools/popup/generator/generator.component";
import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component"; import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component";
@@ -124,6 +125,7 @@ import "../platform/popup/locales";
PopupFooterComponent, PopupFooterComponent,
PopupHeaderComponent, PopupHeaderComponent,
UserVerificationDialogComponent, UserVerificationDialogComponent,
PopupSectionHeaderComponent,
], ],
declarations: [ declarations: [
ActionButtonsComponent, ActionButtonsComponent,

View File

@@ -195,9 +195,11 @@ const safeProviders: SafeProvider[] = [
}), }),
safeProvider({ safeProvider({
provide: LogService, provide: LogService,
useFactory: (platformUtilsService: PlatformUtilsService) => useFactory: () => {
new ConsoleLogService(platformUtilsService.isDev()), const isDev = process.env.ENV === "development";
deps: [PlatformUtilsService], return new ConsoleLogService(isDev);
},
deps: [],
}), }),
safeProvider({ safeProvider({
provide: EnvironmentService, provide: EnvironmentService,
@@ -286,7 +288,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: OffscreenDocumentService, provide: OffscreenDocumentService,
useClass: DefaultOffscreenDocumentService, useClass: DefaultOffscreenDocumentService,
deps: [], deps: [LogService],
}), }),
safeProvider({ safeProvider({
provide: PlatformUtilsService, provide: PlatformUtilsService,

View File

@@ -1,5 +1,9 @@
<bit-layout variant="secondary"> <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"> <a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'adminConsole' | i18n">
<bit-icon [icon]="logo"></bit-icon> <bit-icon [icon]="logo"></bit-icon>
</a> </a>
@@ -106,6 +110,8 @@
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </nav>

View File

@@ -25,6 +25,7 @@ import { BannerModule, IconModule, LayoutComponent, NavigationModule } from "@bi
import { PaymentMethodWarningsModule } from "../../../billing/shared"; import { PaymentMethodWarningsModule } from "../../../billing/shared";
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; 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 { ToggleWidthComponent } from "../../../layouts/toggle-width.component";
import { AdminConsoleLogo } from "../../icons/admin-console-logo"; import { AdminConsoleLogo } from "../../icons/admin-console-logo";
@@ -43,6 +44,7 @@ import { AdminConsoleLogo } from "../../icons/admin-console-logo";
BannerModule, BannerModule,
PaymentMethodWarningsModule, PaymentMethodWarningsModule,
ToggleWidthComponent, ToggleWidthComponent,
ProductSwitcherModule,
], ],
}) })
export class OrganizationLayoutComponent implements OnInit, OnDestroy { export class OrganizationLayoutComponent implements OnInit, OnDestroy {

View File

@@ -4,7 +4,7 @@
</span> </span>
<span bitDialogContent> <span bitDialogContent>
<bit-callout type="warning">{{ "changeKdfLoggedOutWarning" | i18n }}</bit-callout> <bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
<form <form
id="form" id="form"
[formGroup]="form" [formGroup]="form"

View File

@@ -1,7 +1,7 @@
<div class="tabbed-header"> <div class="tabbed-header">
<h1>{{ "encKeySettings" | i18n }}</h1> <h1>{{ "encKeySettings" | i18n }}</h1>
</div> </div>
<bit-callout type="warning">{{ "changeKdfLoggedOutWarning" | i18n }}</bit-callout> <bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
<form #form ngNativeValidate autocomplete="off"> <form #form ngNativeValidate autocomplete="off">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">

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 { 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"; import { MenuComponent } from "@bitwarden/components";
type ProductSwitcherItem = { import { ProductSwitcherService } from "./shared/product-switcher.service";
/**
* 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;
};
@Component({ @Component({
selector: "product-switcher-content", selector: "product-switcher-content",
@@ -45,106 +12,7 @@ export class ProductSwitcherContentComponent {
@ViewChild("menu") @ViewChild("menu")
menu: MenuComponent; menu: MenuComponent;
protected products$ = combineLatest([ constructor(private productSwitcherService: ProductSwitcherService) {}
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);
// If the active route org doesn't have access to AC, find the first org that does. protected readonly products$ = this.productSwitcherService.products$;
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,
) {}
} }

View File

@@ -3,16 +3,22 @@ import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { NavigationModule } from "@bitwarden/components";
import { SharedModule } from "../../shared"; import { SharedModule } from "../../shared";
import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component";
import { ProductSwitcherContentComponent } from "./product-switcher-content.component"; import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
import { ProductSwitcherComponent } from "./product-switcher.component"; import { ProductSwitcherComponent } from "./product-switcher.component";
@NgModule({ @NgModule({
imports: [SharedModule, A11yModule, RouterModule], imports: [SharedModule, A11yModule, RouterModule, NavigationModule],
declarations: [ProductSwitcherComponent, ProductSwitcherContentComponent], declarations: [
exports: [ProductSwitcherComponent], ProductSwitcherComponent,
ProductSwitcherContentComponent,
NavigationProductSwitcherComponent,
],
exports: [ProductSwitcherComponent, NavigationProductSwitcherComponent],
providers: [I18nPipe], providers: [I18nPipe],
}) })
export class ProductSwitcherModule {} export class ProductSwitcherModule {}

View File

@@ -1,6 +1,6 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core"; import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, Story } from "@storybook/angular"; import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BehaviorSubject, firstValueFrom } from "rxjs"; import { BehaviorSubject, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { ProductSwitcherContentComponent } from "./product-switcher-content.component";
import { ProductSwitcherComponent } from "./product-switcher.component"; import { ProductSwitcherComponent } from "./product-switcher.component";
import { ProductSwitcherService } from "./shared/product-switcher.service";
@Directive({ @Directive({
selector: "[mockOrgs]", selector: "[mockOrgs]",
@@ -74,12 +75,15 @@ export default {
MockOrganizationService, MockOrganizationService,
{ provide: ProviderService, useClass: MockProviderService }, { provide: ProviderService, useClass: MockProviderService },
MockProviderService, MockProviderService,
ProductSwitcherService,
{ {
provide: I18nService, provide: I18nService,
useFactory: () => { useFactory: () => {
return new I18nMockService({ return new I18nMockService({
moreFromBitwarden: "More from Bitwarden", moreFromBitwarden: "More from Bitwarden",
switchProducts: "Switch Products", 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) => ({ type Story = StoryObj<ProductSwitcherComponent & MockProviderService & MockOrganizationService>;
props: args,
template: ` const Template: Story = {
render: (args) => ({
props: args,
template: `
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet> <router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
<div class="tw-flex tw-gap-[200px]"> <div class="tw-flex tw-gap-[200px]">
<div> <div>
@@ -142,28 +149,42 @@ const Template: Story = (args) => ({
</div> </div>
</div> </div>
`, `,
}); }),
};
export const OnlyPM = Template.bind({}); export const OnlyPM: Story = {
OnlyPM.args = { ...Template,
mockOrgs: [], args: {
mockProviders: [], mockOrgs: [],
mockProviders: [],
},
}; };
export const WithSM = Template.bind({}); export const WithSM: Story = {
WithSM.args = { ...Template,
mockOrgs: [{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true }], args: {
mockProviders: [], mockOrgs: [
{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
}; };
export const WithSMAndAC = Template.bind({}); export const WithSMAndAC: Story = {
WithSMAndAC.args = { ...Template,
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }], args: {
mockProviders: [], mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
}; };
export const WithAllOptions = Template.bind({}); export const WithAllOptions: Story = {
WithAllOptions.args = { ...Template,
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }], args: {
mockProviders: [{ id: "provider-a" }], 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" text="Toggle Width"
icon="bwi-bug" icon="bwi-bug"
*ngIf="isDev" *ngIf="isDev"
class="tw-absolute tw-bottom-0 tw-w-full"
(click)="toggleWidth()" (click)="toggleWidth()"
></bit-nav-item>`, ></bit-nav-item>`,
standalone: true, standalone: true,

View File

@@ -1,5 +1,5 @@
<bit-layout> <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"> <a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'passwordManager' | i18n">
<bit-icon [icon]="logo"></bit-icon> <bit-icon [icon]="logo"></bit-icon>
</a> </a>
@@ -33,6 +33,8 @@
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </nav>
<app-payment-method-warnings <app-payment-method-warnings

View File

@@ -16,6 +16,7 @@ import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/compon
import { PaymentMethodWarningsModule } from "../billing/shared"; import { PaymentMethodWarningsModule } from "../billing/shared";
import { PasswordManagerLogo } from "./password-manager-logo"; import { PasswordManagerLogo } from "./password-manager-logo";
import { ProductSwitcherModule } from "./product-switcher/product-switcher.module";
import { ToggleWidthComponent } from "./toggle-width.component"; import { ToggleWidthComponent } from "./toggle-width.component";
@Component({ @Component({
@@ -31,6 +32,7 @@ import { ToggleWidthComponent } from "./toggle-width.component";
NavigationModule, NavigationModule,
PaymentMethodWarningsModule, PaymentMethodWarningsModule,
ToggleWidthComponent, ToggleWidthComponent,
ProductSwitcherModule,
], ],
}) })
export class UserLayoutComponent implements OnInit { export class UserLayoutComponent implements OnInit {

View File

@@ -69,6 +69,13 @@
<span>{{ "readOnlyCollectionAccess" | i18n }}</span> <span>{{ "readOnlyCollectionAccess" | i18n }}</span>
</ng-container> </ng-container>
<ng-container *ngIf="!dialogReadonly"> <ng-container *ngIf="!dialogReadonly">
<bit-callout
title="{{ 'grantAddAccessCollectionWarningTitle' | i18n }}"
type="warning"
*ngIf="showAddAccessWarning"
>
{{ "grantAddAccessCollectionWarning" | i18n }}
</bit-callout>
<span *ngIf="organization.useGroups">{{ "grantCollectionAccess" | i18n }}</span> <span *ngIf="organization.useGroups">{{ "grantCollectionAccess" | i18n }}</span>
<span *ngIf="!organization.useGroups">{{ <span *ngIf="!organization.useGroups">{{
"grantCollectionAccessMembersOnly" | i18n "grantCollectionAccessMembersOnly" | i18n
@@ -84,7 +91,10 @@
</div> </div>
<div <div
class="tw-mb-3 tw-text-danger" class="tw-mb-3 tw-text-danger"
*ngIf="formGroup.controls.access.hasError('managePermissionRequired')" *ngIf="
formGroup.controls.access.hasError('managePermissionRequired') &&
!showAddAccessWarning
"
> >
<i class="bwi bwi-error"></i> {{ "managePermissionRequired" | i18n }} <i class="bwi bwi-error"></i> {{ "managePermissionRequired" | i18n }}
</div> </div>

View File

@@ -59,6 +59,7 @@ export interface CollectionDialogParams {
*/ */
limitNestedCollections?: boolean; limitNestedCollections?: boolean;
readonly?: boolean; readonly?: boolean;
isAddAccessCollection?: boolean;
} }
export interface CollectionDialogResult { export interface CollectionDialogResult {
@@ -100,6 +101,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
}); });
protected PermissionMode = PermissionMode; protected PermissionMode = PermissionMode;
protected showDeleteButton = false; protected showDeleteButton = false;
protected showAddAccessWarning = false;
constructor( constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams, @Inject(DIALOG_DATA) private params: CollectionDialogParams,
@@ -251,6 +253,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.handleFormGroupReadonly(this.dialogReadonly); this.handleFormGroupReadonly(this.dialogReadonly);
this.loading = false; this.loading = false;
this.showAddAccessWarning = this.handleAddAccessWarning(flexibleCollectionsV1);
}, },
); );
} }
@@ -362,6 +365,18 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
private handleAddAccessWarning(flexibleCollectionsV1: boolean): boolean {
if (
flexibleCollectionsV1 &&
!this.organization?.allowAdminAccessToAllCollectionItems &&
this.params.isAddAccessCollection
) {
return true;
}
return false;
}
private handleFormGroupReadonly(readonly: boolean) { private handleFormGroupReadonly(readonly: boolean) {
if (readonly) { if (readonly) {
this.formGroup.controls.name.disable(); this.formGroup.controls.name.disable();

View File

@@ -1222,6 +1222,7 @@ export class VaultComponent implements OnInit, OnDestroy {
organizationId: this.organization?.id, organizationId: this.organization?.id,
initialTab: tab, initialTab: tab,
readonly: readonly, readonly: readonly,
isAddAccessCollection: c.addAccess,
limitNestedCollections: !this.organization.canEditAnyCollection( limitNestedCollections: !this.organization.canEditAnyCollection(
this.flexibleCollectionsV1Enabled, this.flexibleCollectionsV1Enabled,
), ),

View File

@@ -7014,8 +7014,8 @@
"updateLowKdfIterationsDesc": { "updateLowKdfIterationsDesc": {
"message": "Update your encryption settings to meet new security recommendations and improve account protection." "message": "Update your encryption settings to meet new security recommendations and improve account protection."
}, },
"changeKdfLoggedOutWarning": { "kdfSettingsChangeLogoutWarning": {
"message": "Proceeding will log you out of all active sessions. You will need to log back in and complete two-step login setup. We recommend exporting your vault before changing your encryption settings to prevent data loss." "message": "Proceeding will log you out of all active sessions. You will need to log back in and complete two-step login, if any. We recommend exporting your vault before changing your encryption settings to prevent data loss."
}, },
"secretsManager": { "secretsManager": {
"message": "Secrets Manager" "message": "Secrets Manager"
@@ -7635,6 +7635,12 @@
"readOnlyCollectionAccess": { "readOnlyCollectionAccess": {
"message": "You do not have access to manage this collection." "message": "You do not have access to manage this collection."
}, },
"grantAddAccessCollectionWarningTitle": {
"message": "Missing Can Manage Permissions"
},
"grantAddAccessCollectionWarning": {
"message": "Grant Can manage permissions to allow full collection management including deletion of collection."
},
"grantCollectionAccess": { "grantCollectionAccess": {
"message": "Grant groups or members access to this collection." "message": "Grant groups or members access to this collection."
}, },
@@ -8210,5 +8216,11 @@
"example": "2" "example": "2"
} }
} }
},
"secureYourInfrastructure": {
"message": "Secure your infrastructure"
},
"protectYourFamilyOrBusiness": {
"message": "Protect your family or business"
} }
} }

View File

@@ -1,5 +1,5 @@
<bit-layout variant="secondary"> <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"> <a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'providerPortal' | i18n">
<bit-icon [icon]="logo"></bit-icon> <bit-icon [icon]="logo"></bit-icon>
</a> </a>
@@ -40,6 +40,9 @@
route="settings" route="settings"
*ngIf="showSettingsTab(provider)" *ngIf="showSettingsTab(provider)"
></bit-nav-item> ></bit-nav-item>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </nav>
<app-payment-method-warnings <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 { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo"; import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared"; 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"; import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component";
@Component({ @Component({
@@ -28,6 +29,7 @@ import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-wi
NavigationModule, NavigationModule,
PaymentMethodWarningsModule, PaymentMethodWarningsModule,
ToggleWidthComponent, ToggleWidthComponent,
ProductSwitcherModule,
], ],
}) })
export class ProvidersLayoutComponent implements OnInit, OnDestroy { 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 { LayoutComponent as BitLayoutComponent, NavigationModule } from "@bitwarden/components";
import { OrgSwitcherComponent } from "@bitwarden/web-vault/app/layouts/org-switcher/org-switcher.component"; 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 { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
@@ -15,6 +16,7 @@ import { NavigationComponent } from "./navigation.component";
BitLayoutComponent, BitLayoutComponent,
OrgSwitcherComponent, OrgSwitcherComponent,
ToggleWidthComponent, ToggleWidthComponent,
ProductSwitcherModule,
], ],
declarations: [LayoutComponent, NavigationComponent], 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"> <a routerLink="." class="tw-m-5 tw-mt-7 tw-block">
<bit-icon [icon]="logo"></bit-icon> <bit-icon [icon]="logo"></bit-icon>
</a> </a>
@@ -48,5 +48,7 @@
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </nav>

View File

@@ -1,11 +1,11 @@
import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncString } from "../../../platform/models/domain/enc-string"; import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { OrgKey } from "../../../types/key"; import { OrgKey, UserPrivateKey } from "../../../types/key";
import { EncryptedOrganizationKeyData } from "../data/encrypted-organization-key.data"; import { EncryptedOrganizationKeyData } from "../data/encrypted-organization-key.data";
export abstract class BaseEncryptedOrganizationKey { export abstract class BaseEncryptedOrganizationKey {
decrypt: (cryptoService: CryptoService) => Promise<SymmetricCryptoKey>; abstract get encryptedOrganizationKey(): EncString;
static fromData(data: EncryptedOrganizationKeyData) { static fromData(data: EncryptedOrganizationKeyData) {
switch (data.type) { switch (data.type) {
@@ -19,22 +19,26 @@ export abstract class BaseEncryptedOrganizationKey {
return null; return null;
} }
} }
static isProviderEncrypted(
key: EncryptedOrganizationKey | ProviderEncryptedOrganizationKey,
): key is ProviderEncryptedOrganizationKey {
return key.toData().type === "provider";
}
} }
export class EncryptedOrganizationKey implements BaseEncryptedOrganizationKey { export class EncryptedOrganizationKey implements BaseEncryptedOrganizationKey {
constructor(private key: string) {} constructor(private key: string) {}
async decrypt(cryptoService: CryptoService) { async decrypt(encryptService: EncryptService, privateKey: UserPrivateKey) {
const activeUserPrivateKey = await cryptoService.getPrivateKey(); const decValue = await encryptService.rsaDecrypt(this.encryptedOrganizationKey, privateKey);
if (activeUserPrivateKey == null) {
throw new Error("Active user does not have a private key, cannot decrypt organization key.");
}
const decValue = await cryptoService.rsaDecrypt(this.key, activeUserPrivateKey);
return new SymmetricCryptoKey(decValue) as OrgKey; return new SymmetricCryptoKey(decValue) as OrgKey;
} }
get encryptedOrganizationKey() {
return new EncString(this.key);
}
toData(): EncryptedOrganizationKeyData { toData(): EncryptedOrganizationKeyData {
return { return {
type: "organization", type: "organization",
@@ -49,12 +53,18 @@ export class ProviderEncryptedOrganizationKey implements BaseEncryptedOrganizati
private providerId: string, private providerId: string,
) {} ) {}
async decrypt(cryptoService: CryptoService) { async decrypt(encryptService: EncryptService, providerKeys: Record<string, SymmetricCryptoKey>) {
const providerKey = await cryptoService.getProviderKey(this.providerId); const decValue = await encryptService.decryptToBytes(
const decValue = await cryptoService.decryptToBytes(new EncString(this.key), providerKey); new EncString(this.key),
providerKeys[this.providerId],
);
return new SymmetricCryptoKey(decValue) as OrgKey; return new SymmetricCryptoKey(decValue) as OrgKey;
} }
get encryptedOrganizationKey() {
return new EncString(this.key);
}
toData(): EncryptedOrganizationKeyData { toData(): EncryptedOrganizationKeyData {
return { return {
type: "provider", type: "provider",

View File

@@ -12,8 +12,8 @@ export const getCommand = (commandDefinition: CommandDefinition<object> | string
export const EXTERNAL_SOURCE_TAG = Symbol("externalSource"); export const EXTERNAL_SOURCE_TAG = Symbol("externalSource");
export const isExternalMessage = (message: Message<object>) => { export const isExternalMessage = (message: Record<PropertyKey, unknown>) => {
return (message as Record<PropertyKey, unknown>)?.[EXTERNAL_SOURCE_TAG] === true; return message?.[EXTERNAL_SOURCE_TAG] === true;
}; };
export const tagAsExternal: MonoTypeOperatorFunction<Message<object>> = map( export const tagAsExternal: MonoTypeOperatorFunction<Message<object>> = map(

View File

@@ -11,6 +11,7 @@ export type SharedFlags = {
export type SharedDevFlags = { export type SharedDevFlags = {
noopNotifications: boolean; noopNotifications: boolean;
skipWelcomeOnInstall: boolean; skipWelcomeOnInstall: boolean;
configRetrievalIntervalMs: number;
}; };
function getFlags<T>(envFlags: string | T): T { function getFlags<T>(envFlags: string | T): T {

View File

@@ -1,13 +1,13 @@
import { import {
NEVER,
Observable,
Subject,
combineLatest, combineLatest,
firstValueFrom, firstValueFrom,
map, map,
mergeWith, mergeWith,
NEVER,
Observable,
of, of,
shareReplay, shareReplay,
Subject,
switchMap, switchMap,
tap, tap,
} from "rxjs"; } from "rxjs";
@@ -24,10 +24,13 @@ import { ConfigService } from "../../abstractions/config/config.service";
import { ServerConfig } from "../../abstractions/config/server-config"; import { ServerConfig } from "../../abstractions/config/server-config";
import { EnvironmentService, Region } from "../../abstractions/environment.service"; import { EnvironmentService, Region } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service"; import { LogService } from "../../abstractions/log.service";
import { devFlagEnabled, devFlagValue } from "../../misc/flags";
import { ServerConfigData } from "../../models/data/server-config.data"; import { ServerConfigData } from "../../models/data/server-config.data";
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state"; import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
export const RETRIEVAL_INTERVAL = 3_600_000; // 1 hour export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs")
? (devFlagValue("configRetrievalIntervalMs") as number)
: 3_600_000; // 1 hour
export type ApiUrl = string; export type ApiUrl = string;

View File

@@ -1,5 +1,5 @@
import * as bigInt from "big-integer"; import * as bigInt from "big-integer";
import { Observable, filter, firstValueFrom, map } from "rxjs"; import { Observable, filter, firstValueFrom, map, zip } from "rxjs";
import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions"; import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions";
import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data"; import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data";
@@ -97,13 +97,12 @@ export class CryptoService implements CryptoServiceAbstraction {
// User Asymmetric Key Pair // User Asymmetric Key Pair
this.activeUserEncryptedPrivateKeyState = stateProvider.getActive(USER_ENCRYPTED_PRIVATE_KEY); this.activeUserEncryptedPrivateKeyState = stateProvider.getActive(USER_ENCRYPTED_PRIVATE_KEY);
this.activeUserPrivateKeyState = stateProvider.getDerived( this.activeUserPrivateKeyState = stateProvider.getDerived(
this.activeUserEncryptedPrivateKeyState.combinedState$.pipe( zip(this.activeUserEncryptedPrivateKeyState.state$, this.activeUserKey$).pipe(
filter(([_userId, key]) => key != null), filter(([, userKey]) => !!userKey),
), ),
USER_PRIVATE_KEY, USER_PRIVATE_KEY,
{ {
encryptService: this.encryptService, encryptService: this.encryptService,
getUserKey: (userId) => this.getUserKey(userId),
}, },
); );
this.activeUserPrivateKey$ = this.activeUserPrivateKeyState.state$; // may be null this.activeUserPrivateKey$ = this.activeUserPrivateKeyState.state$; // may be null
@@ -116,27 +115,34 @@ export class CryptoService implements CryptoServiceAbstraction {
); );
this.activeUserPublicKey$ = this.activeUserPublicKeyState.state$; // may be null this.activeUserPublicKey$ = this.activeUserPublicKeyState.state$; // may be null
// Organization keys
this.activeUserEncryptedOrgKeysState = stateProvider.getActive(
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
this.activeUserOrgKeysState = stateProvider.getDerived(
this.activeUserEncryptedOrgKeysState.state$.pipe(filter((keys) => keys != null)),
USER_ORGANIZATION_KEYS,
{ cryptoService: this },
);
this.activeUserOrgKeys$ = this.activeUserOrgKeysState.state$; // null handled by `derive` function
// Provider keys // Provider keys
this.activeUserEncryptedProviderKeysState = stateProvider.getActive( this.activeUserEncryptedProviderKeysState = stateProvider.getActive(
USER_ENCRYPTED_PROVIDER_KEYS, USER_ENCRYPTED_PROVIDER_KEYS,
); );
this.activeUserProviderKeysState = stateProvider.getDerived( this.activeUserProviderKeysState = stateProvider.getDerived(
this.activeUserEncryptedProviderKeysState.state$.pipe(filter((keys) => keys != null)), zip(
this.activeUserEncryptedProviderKeysState.state$.pipe(filter((keys) => keys != null)),
this.activeUserPrivateKey$,
).pipe(filter(([, privateKey]) => !!privateKey)),
USER_PROVIDER_KEYS, USER_PROVIDER_KEYS,
{ encryptService: this.encryptService, cryptoService: this }, { encryptService: this.encryptService },
); );
this.activeUserProviderKeys$ = this.activeUserProviderKeysState.state$; // null handled by `derive` function this.activeUserProviderKeys$ = this.activeUserProviderKeysState.state$; // null handled by `derive` function
// Organization keys
this.activeUserEncryptedOrgKeysState = stateProvider.getActive(
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
this.activeUserOrgKeysState = stateProvider.getDerived(
zip(
this.activeUserEncryptedOrgKeysState.state$.pipe(filter((keys) => keys != null)),
this.activeUserPrivateKey$,
this.activeUserProviderKeys$,
).pipe(filter(([, privateKey]) => !!privateKey)),
USER_ORGANIZATION_KEYS,
{ encryptService: this.encryptService },
);
this.activeUserOrgKeys$ = this.activeUserOrgKeysState.state$; // null handled by `derive` function
} }
async setUserKey(key: UserKey, userId?: UserId): Promise<void> { async setUserKey(key: UserKey, userId?: UserId): Promise<void> {
@@ -656,17 +662,14 @@ export class CryptoService implements CryptoServiceAbstraction {
} }
try { try {
const [userId, encPrivateKey] = await firstValueFrom( const encPrivateKey = await firstValueFrom(this.activeUserEncryptedPrivateKeyState.state$);
this.activeUserEncryptedPrivateKeyState.combinedState$,
);
if (encPrivateKey == null) { if (encPrivateKey == null) {
return false; return false;
} }
// Can decrypt private key // Can decrypt private key
const privateKey = await USER_PRIVATE_KEY.derive([userId, encPrivateKey], { const privateKey = await USER_PRIVATE_KEY.derive([encPrivateKey, key], {
encryptService: this.encryptService, encryptService: this.encryptService,
getUserKey: () => Promise.resolve(key),
}); });
if (privateKey == null) { if (privateKey == null) {

View File

@@ -1,8 +1,8 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { makeEncString, makeStaticByteArray } from "../../../../spec"; import { makeEncString, makeStaticByteArray } from "../../../../spec";
import { OrgKey } from "../../../types/key"; import { OrgKey, UserPrivateKey } from "../../../types/key";
import { CryptoService } from "../../abstractions/crypto.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ORGANIZATION_KEYS } from "./org-keys.state"; import { USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ORGANIZATION_KEYS } from "./org-keys.state";
@@ -30,7 +30,8 @@ describe("encrypted org keys", () => {
}); });
describe("derived decrypted org keys", () => { describe("derived decrypted org keys", () => {
const cryptoService = mock<CryptoService>(); const encryptService = mock<EncryptService>();
const userPrivateKey = makeStaticByteArray(64, 3) as UserPrivateKey;
const sut = USER_ORGANIZATION_KEYS; const sut = USER_ORGANIZATION_KEYS;
afterEach(() => { afterEach(() => {
@@ -65,15 +66,11 @@ describe("derived decrypted org keys", () => {
"org-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as OrgKey, "org-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as OrgKey,
}; };
const userPrivateKey = makeStaticByteArray(64, 3);
cryptoService.getPrivateKey.mockResolvedValue(userPrivateKey);
// TODO: How to not have to mock these decryptions. They are internal concerns of EncryptedOrganizationKey // TODO: How to not have to mock these decryptions. They are internal concerns of EncryptedOrganizationKey
cryptoService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key); encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key);
cryptoService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key); encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key);
const result = await sut.derive(encryptedOrgKeys, { cryptoService }); const result = await sut.derive([encryptedOrgKeys, userPrivateKey, {}], { encryptService });
expect(result).toEqual(decryptedOrgKeys); expect(result).toEqual(decryptedOrgKeys);
}); });
@@ -92,16 +89,23 @@ describe("derived decrypted org keys", () => {
}, },
}; };
const providerKeys = {
"provider-id-1": new SymmetricCryptoKey(makeStaticByteArray(64, 1)),
"provider-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)),
};
const decryptedOrgKeys = { const decryptedOrgKeys = {
"org-id-1": new SymmetricCryptoKey(makeStaticByteArray(64, 1)) as OrgKey, "org-id-1": new SymmetricCryptoKey(makeStaticByteArray(64, 1)) as OrgKey,
"org-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as OrgKey, "org-id-2": new SymmetricCryptoKey(makeStaticByteArray(64, 2)) as OrgKey,
}; };
// TODO: How to not have to mock these decryptions. They are internal concerns of ProviderEncryptedOrganizationKey // TODO: How to not have to mock these decryptions. They are internal concerns of ProviderEncryptedOrganizationKey
cryptoService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key); encryptService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-1"].key);
cryptoService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key); encryptService.decryptToBytes.mockResolvedValueOnce(decryptedOrgKeys["org-id-2"].key);
const result = await sut.derive(encryptedOrgKeys, { cryptoService }); const result = await sut.derive([encryptedOrgKeys, userPrivateKey, providerKeys], {
encryptService,
});
expect(result).toEqual(decryptedOrgKeys); expect(result).toEqual(decryptedOrgKeys);
}); });

View File

@@ -1,10 +1,10 @@
import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data"; import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data";
import { BaseEncryptedOrganizationKey } from "../../../admin-console/models/domain/encrypted-organization-key"; import { BaseEncryptedOrganizationKey } from "../../../admin-console/models/domain/encrypted-organization-key";
import { OrganizationId } from "../../../types/guid"; import { OrganizationId, ProviderId } from "../../../types/guid";
import { OrgKey } from "../../../types/key"; import { OrgKey, ProviderKey, UserPrivateKey } from "../../../types/key";
import { CryptoService } from "../../abstractions/crypto.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; import { CRYPTO_DISK, CRYPTO_MEMORY, DeriveDefinition, UserKeyDefinition } from "../../state";
export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record< export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record<
EncryptedOrganizationKeyData, EncryptedOrganizationKeyData,
@@ -14,11 +14,15 @@ export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record<
clearOn: ["logout"], clearOn: ["logout"],
}); });
export const USER_ORGANIZATION_KEYS = DeriveDefinition.from< export const USER_ORGANIZATION_KEYS = new DeriveDefinition<
Record<OrganizationId, EncryptedOrganizationKeyData>, [
Record<OrganizationId, EncryptedOrganizationKeyData>,
UserPrivateKey,
Record<ProviderId, ProviderKey>,
],
Record<OrganizationId, OrgKey>, Record<OrganizationId, OrgKey>,
{ cryptoService: CryptoService } { encryptService: EncryptService }
>(USER_ENCRYPTED_ORGANIZATION_KEYS, { >(CRYPTO_MEMORY, "organizationKeys", {
deserializer: (obj) => { deserializer: (obj) => {
const result: Record<OrganizationId, OrgKey> = {}; const result: Record<OrganizationId, OrgKey> = {};
for (const orgId of Object.keys(obj ?? {}) as OrganizationId[]) { for (const orgId of Object.keys(obj ?? {}) as OrganizationId[]) {
@@ -26,14 +30,21 @@ export const USER_ORGANIZATION_KEYS = DeriveDefinition.from<
} }
return result; return result;
}, },
derive: async (from, { cryptoService }) => { derive: async ([encryptedOrgKeys, privateKey, providerKeys], { encryptService }) => {
const result: Record<OrganizationId, OrgKey> = {}; const result: Record<OrganizationId, OrgKey> = {};
for (const orgId of Object.keys(from ?? {}) as OrganizationId[]) { for (const orgId of Object.keys(encryptedOrgKeys ?? {}) as OrganizationId[]) {
if (result[orgId] != null) { if (result[orgId] != null) {
continue; continue;
} }
const encrypted = BaseEncryptedOrganizationKey.fromData(from[orgId]); const encrypted = BaseEncryptedOrganizationKey.fromData(encryptedOrgKeys[orgId]);
const decrypted = await encrypted.decrypt(cryptoService);
let decrypted: OrgKey;
if (BaseEncryptedOrganizationKey.isProviderEncrypted(encrypted)) {
decrypted = await encrypted.decrypt(encryptService, providerKeys);
} else {
decrypted = await encrypted.decrypt(encryptService, privateKey);
}
result[orgId] = decrypted; result[orgId] = decrypted;
} }

View File

@@ -6,7 +6,6 @@ import { ProviderKey, UserPrivateKey } from "../../../types/key";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { EncryptedString } from "../../models/domain/enc-string"; import { EncryptedString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { CryptoService } from "../crypto.service";
import { USER_ENCRYPTED_PROVIDER_KEYS, USER_PROVIDER_KEYS } from "./provider-keys.state"; import { USER_ENCRYPTED_PROVIDER_KEYS, USER_PROVIDER_KEYS } from "./provider-keys.state";
@@ -27,7 +26,6 @@ describe("encrypted provider keys", () => {
describe("derived decrypted provider keys", () => { describe("derived decrypted provider keys", () => {
const encryptService = mock<EncryptService>(); const encryptService = mock<EncryptService>();
const cryptoService = mock<CryptoService>();
const userPrivateKey = makeStaticByteArray(64, 0) as UserPrivateKey; const userPrivateKey = makeStaticByteArray(64, 0) as UserPrivateKey;
const sut = USER_PROVIDER_KEYS; const sut = USER_PROVIDER_KEYS;
@@ -59,9 +57,8 @@ describe("derived decrypted provider keys", () => {
encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-1"].key); encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-1"].key);
encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-2"].key); encryptService.rsaDecrypt.mockResolvedValueOnce(decryptedProviderKeys["provider-id-2"].key);
cryptoService.getPrivateKey.mockResolvedValueOnce(userPrivateKey);
const result = await sut.derive(encryptedProviderKeys, { encryptService, cryptoService }); const result = await sut.derive([encryptedProviderKeys, userPrivateKey], { encryptService });
expect(result).toEqual(decryptedProviderKeys); expect(result).toEqual(decryptedProviderKeys);
}); });
@@ -69,7 +66,7 @@ describe("derived decrypted provider keys", () => {
it("should handle null input values", async () => { it("should handle null input values", async () => {
const encryptedProviderKeys: Record<ProviderId, EncryptedString> = null; const encryptedProviderKeys: Record<ProviderId, EncryptedString> = null;
const result = await sut.derive(encryptedProviderKeys, { encryptService, cryptoService }); const result = await sut.derive([encryptedProviderKeys, userPrivateKey], { encryptService });
expect(result).toEqual({}); expect(result).toEqual({});
}); });

View File

@@ -1,10 +1,9 @@
import { ProviderId } from "../../../types/guid"; import { ProviderId } from "../../../types/guid";
import { ProviderKey } from "../../../types/key"; import { ProviderKey, UserPrivateKey } from "../../../types/key";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { EncString, EncryptedString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; import { CRYPTO_DISK, CRYPTO_MEMORY, DeriveDefinition, UserKeyDefinition } from "../../state";
import { CryptoService } from "../crypto.service";
export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record<EncryptedString, ProviderId>( export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record<EncryptedString, ProviderId>(
CRYPTO_DISK, CRYPTO_DISK,
@@ -15,11 +14,11 @@ export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record<EncryptedSt
}, },
); );
export const USER_PROVIDER_KEYS = DeriveDefinition.from< export const USER_PROVIDER_KEYS = new DeriveDefinition<
Record<ProviderId, EncryptedString>, [Record<ProviderId, EncryptedString>, UserPrivateKey],
Record<ProviderId, ProviderKey>, Record<ProviderId, ProviderKey>,
{ encryptService: EncryptService; cryptoService: CryptoService } // TODO: This should depend on an active user private key observable directly { encryptService: EncryptService }
>(USER_ENCRYPTED_PROVIDER_KEYS, { >(CRYPTO_MEMORY, "providerKeys", {
deserializer: (obj) => { deserializer: (obj) => {
const result: Record<ProviderId, ProviderKey> = {}; const result: Record<ProviderId, ProviderKey> = {};
for (const providerId of Object.keys(obj ?? {}) as ProviderId[]) { for (const providerId of Object.keys(obj ?? {}) as ProviderId[]) {
@@ -27,14 +26,13 @@ export const USER_PROVIDER_KEYS = DeriveDefinition.from<
} }
return result; return result;
}, },
derive: async (from, { encryptService, cryptoService }) => { derive: async ([encryptedProviderKeys, privateKey], { encryptService }) => {
const result: Record<ProviderId, ProviderKey> = {}; const result: Record<ProviderId, ProviderKey> = {};
for (const providerId of Object.keys(from ?? {}) as ProviderId[]) { for (const providerId of Object.keys(encryptedProviderKeys ?? {}) as ProviderId[]) {
if (result[providerId] != null) { if (result[providerId] != null) {
continue; continue;
} }
const encrypted = new EncString(from[providerId]); const encrypted = new EncString(encryptedProviderKeys[providerId]);
const privateKey = await cryptoService.getPrivateKey();
const decrypted = await encryptService.rsaDecrypt(encrypted, privateKey); const decrypted = await encryptService.rsaDecrypt(encrypted, privateKey);
const providerKey = new SymmetricCryptoKey(decrypted) as ProviderKey; const providerKey = new SymmetricCryptoKey(decrypted) as ProviderKey;

View File

@@ -1,7 +1,6 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { makeStaticByteArray } from "../../../../spec"; import { makeStaticByteArray } from "../../../../spec";
import { UserId } from "../../../types/guid";
import { UserKey, UserPrivateKey, UserPublicKey } from "../../../types/key"; import { UserKey, UserPrivateKey, UserPublicKey } from "../../../types/key";
import { CryptoFunctionService } from "../../abstractions/crypto-function.service"; import { CryptoFunctionService } from "../../abstractions/crypto-function.service";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
@@ -70,7 +69,6 @@ describe("User public key", () => {
describe("Derived decrypted private key", () => { describe("Derived decrypted private key", () => {
const sut = USER_PRIVATE_KEY; const sut = USER_PRIVATE_KEY;
const userId = "userId" as UserId;
const userKey = mock<UserKey>(); const userKey = mock<UserKey>();
const encryptedPrivateKey = makeEncString().encryptedString; const encryptedPrivateKey = makeEncString().encryptedString;
const decryptedPrivateKey = makeStaticByteArray(64, 1); const decryptedPrivateKey = makeStaticByteArray(64, 1);
@@ -88,37 +86,31 @@ describe("Derived decrypted private key", () => {
}); });
it("should derive decrypted private key", async () => { it("should derive decrypted private key", async () => {
const getUserKey = jest.fn(async () => userKey);
const encryptService = mock<EncryptService>(); const encryptService = mock<EncryptService>();
encryptService.decryptToBytes.mockResolvedValue(decryptedPrivateKey); encryptService.decryptToBytes.mockResolvedValue(decryptedPrivateKey);
const result = await sut.derive([userId, encryptedPrivateKey], { const result = await sut.derive([encryptedPrivateKey, userKey], {
encryptService, encryptService,
getUserKey,
}); });
expect(result).toEqual(decryptedPrivateKey); expect(result).toEqual(decryptedPrivateKey);
}); });
it("should handle null input values", async () => { it("should handle null encryptedPrivateKey", async () => {
const getUserKey = jest.fn(async () => userKey);
const encryptService = mock<EncryptService>(); const encryptService = mock<EncryptService>();
const result = await sut.derive([userId, null], { const result = await sut.derive([null, userKey], {
encryptService, encryptService,
getUserKey,
}); });
expect(result).toEqual(null); expect(result).toEqual(null);
}); });
it("should handle null user key", async () => { it("should handle null userKey", async () => {
const getUserKey = jest.fn(async () => null);
const encryptService = mock<EncryptService>(); const encryptService = mock<EncryptService>();
const result = await sut.derive([userId, encryptedPrivateKey], { const result = await sut.derive([encryptedPrivateKey, null], {
encryptService, encryptService,
getUserKey,
}); });
expect(result).toEqual(null); expect(result).toEqual(null);

View File

@@ -1,4 +1,3 @@
import { UserId } from "../../../types/guid";
import { UserPrivateKey, UserPublicKey, UserKey } from "../../../types/key"; import { UserPrivateKey, UserPublicKey, UserKey } from "../../../types/key";
import { CryptoFunctionService } from "../../abstractions/crypto-function.service"; import { CryptoFunctionService } from "../../abstractions/crypto-function.service";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
@@ -24,20 +23,14 @@ export const USER_ENCRYPTED_PRIVATE_KEY = new UserKeyDefinition<EncryptedString>
}, },
); );
export const USER_PRIVATE_KEY = DeriveDefinition.fromWithUserId< export const USER_PRIVATE_KEY = new DeriveDefinition<
EncryptedString, [EncryptedString, UserKey],
UserPrivateKey, UserPrivateKey,
// TODO: update cryptoService to user key directly { encryptService: EncryptService }
{ encryptService: EncryptService; getUserKey: (userId: UserId) => Promise<UserKey> } >(CRYPTO_MEMORY, "privateKey", {
>(USER_ENCRYPTED_PRIVATE_KEY, {
deserializer: (obj) => new Uint8Array(Object.values(obj)) as UserPrivateKey, deserializer: (obj) => new Uint8Array(Object.values(obj)) as UserPrivateKey,
derive: async ([userId, encPrivateKeyString], { encryptService, getUserKey }) => { derive: async ([encPrivateKeyString, userKey], { encryptService }) => {
if (encPrivateKeyString == null) { if (encPrivateKeyString == null || userKey == null) {
return null;
}
const userKey = await getUserKey(userId);
if (userKey == null) {
return null; return null;
} }
@@ -64,6 +57,7 @@ export const USER_PUBLIC_KEY = DeriveDefinition.from<
return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey;
}, },
}); });
export const USER_KEY = new UserKeyDefinition<UserKey>(CRYPTO_MEMORY, "userKey", { export const USER_KEY = new UserKeyDefinition<UserKey>(CRYPTO_MEMORY, "userKey", {
deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey,
clearOn: ["logout", "lock"], clearOn: ["logout", "lock"],

View File

@@ -0,0 +1,230 @@
import { firstValueFrom, map, of, switchMap } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import {
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../../models/response/notification.response";
import { SendData } from "../../tools/send/models/data/send.data";
import { SendApiService } from "../../tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "../../tools/send/services/send.service.abstraction";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "../../vault/abstractions/sync/sync.service.abstraction";
import { CipherData } from "../../vault/models/data/cipher.data";
import { FolderData } from "../../vault/models/data/folder.data";
import { LogService } from "../abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
/**
* Core SyncService Logic EXCEPT for fullSync so that implementations can differ.
*/
export abstract class CoreSyncService implements SyncService {
syncInProgress = false;
constructor(
protected readonly stateService: StateService,
protected readonly folderService: InternalFolderService,
protected readonly folderApiService: FolderApiServiceAbstraction,
protected readonly messageSender: MessageSender,
protected readonly logService: LogService,
protected readonly cipherService: CipherService,
protected readonly collectionService: CollectionService,
protected readonly apiService: ApiService,
protected readonly accountService: AccountService,
protected readonly authService: AuthService,
protected readonly sendService: InternalSendService,
protected readonly sendApiService: SendApiService,
) {}
abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise<boolean>;
async getLastSync(): Promise<Date> {
if ((await this.stateService.getUserId()) == null) {
return null;
}
const lastSync = await this.stateService.getLastSync();
if (lastSync) {
return new Date(lastSync);
}
return null;
}
async setLastSync(date: Date, userId?: string): Promise<any> {
await this.stateService.setLastSync(date.toJSON(), { userId: userId });
}
async syncUpsertFolder(notification: SyncFolderNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
try {
const localFolder = await this.folderService.get(notification.id);
if (
(!isEdit && localFolder == null) ||
(isEdit && localFolder != null && localFolder.revisionDate < notification.revisionDate)
) {
const remoteFolder = await this.folderApiService.get(notification.id);
if (remoteFolder != null) {
await this.folderService.upsert(new FolderData(remoteFolder));
this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
this.logService.error(e);
}
}
return this.syncCompleted(false);
}
async syncDeleteFolder(notification: SyncFolderNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.folderService.delete(notification.id);
this.messageSender.send("syncedDeletedFolder", { folderId: notification.id });
this.syncCompleted(true);
return true;
}
return this.syncCompleted(false);
}
async syncUpsertCipher(notification: SyncCipherNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
try {
let shouldUpdate = true;
const localCipher = await this.cipherService.get(notification.id);
if (localCipher != null && localCipher.revisionDate >= notification.revisionDate) {
shouldUpdate = false;
}
let checkCollections = false;
if (shouldUpdate) {
if (isEdit) {
shouldUpdate = localCipher != null;
checkCollections = true;
} else {
if (notification.collectionIds == null || notification.organizationId == null) {
shouldUpdate = localCipher == null;
} else {
shouldUpdate = false;
checkCollections = true;
}
}
}
if (
!shouldUpdate &&
checkCollections &&
notification.organizationId != null &&
notification.collectionIds != null &&
notification.collectionIds.length > 0
) {
const collections = await this.collectionService.getAll();
if (collections != null) {
for (let i = 0; i < collections.length; i++) {
if (notification.collectionIds.indexOf(collections[i].id) > -1) {
shouldUpdate = true;
break;
}
}
}
}
if (shouldUpdate) {
const remoteCipher = await this.apiService.getFullCipherDetails(notification.id);
if (remoteCipher != null) {
await this.cipherService.upsert(new CipherData(remoteCipher));
this.messageSender.send("syncedUpsertedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
if (e != null && e.statusCode === 404 && isEdit) {
await this.cipherService.delete(notification.id);
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
}
}
return this.syncCompleted(false);
}
async syncDeleteCipher(notification: SyncCipherNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.cipherService.delete(notification.id);
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
return this.syncCompleted(false);
}
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
const [activeUserId, status] = await firstValueFrom(
this.accountService.activeAccount$.pipe(
switchMap((a) => {
if (a == null) {
of([null, AuthenticationStatus.LoggedOut]);
}
return this.authService.authStatusFor$(a.id).pipe(map((s) => [a.id, s]));
}),
),
);
// Process only notifications for currently active user when user is not logged out
// TODO: once send service allows data manipulation of non-active users, this should process any received notification
if (activeUserId === notification.userId && status !== AuthenticationStatus.LoggedOut) {
try {
const localSend = await firstValueFrom(this.sendService.get$(notification.id));
if (
(!isEdit && localSend == null) ||
(isEdit && localSend != null && localSend.revisionDate < notification.revisionDate)
) {
const remoteSend = await this.sendApiService.getSend(notification.id);
if (remoteSend != null) {
await this.sendService.upsert(new SendData(remoteSend));
this.messageSender.send("syncedUpsertedSend", { sendId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
this.logService.error(e);
}
}
return this.syncCompleted(false);
}
async syncDeleteSend(notification: SyncSendNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.sendService.delete(notification.id);
this.messageSender.send("syncedDeletedSend", { sendId: notification.id });
this.syncCompleted(true);
return true;
}
return this.syncCompleted(false);
}
// Helpers
protected syncStarted() {
this.syncInProgress = true;
this.messageSender.send("syncStarted");
}
protected syncCompleted(successfully: boolean): boolean {
this.syncInProgress = false;
this.messageSender.send("syncCompleted", { successfully: successfully });
return successfully;
}
}

View File

@@ -0,0 +1 @@
export { CoreSyncService } from "./core-sync.service";

View File

@@ -1,4 +1,4 @@
import { firstValueFrom, map, of, switchMap } from "rxjs"; import { firstValueFrom } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
@@ -17,22 +17,17 @@ import { AvatarService } from "../../../auth/abstractions/avatar.service";
import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction";
import { TokenService } from "../../../auth/abstractions/token.service"; import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service";
import { DomainsResponse } from "../../../models/response/domains.response"; import { DomainsResponse } from "../../../models/response/domains.response";
import {
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../../../models/response/notification.response";
import { ProfileResponse } from "../../../models/response/profile.response"; import { ProfileResponse } from "../../../models/response/profile.response";
import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { LogService } from "../../../platform/abstractions/log.service"; import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { StateService } from "../../../platform/abstractions/state.service"; import { StateService } from "../../../platform/abstractions/state.service";
import { MessageSender } from "../../../platform/messaging";
import { sequentialize } from "../../../platform/misc/sequentialize"; import { sequentialize } from "../../../platform/misc/sequentialize";
import { CoreSyncService } from "../../../platform/sync/core-sync.service";
import { SendData } from "../../../tools/send/models/data/send.data"; import { SendData } from "../../../tools/send/models/data/send.data";
import { SendResponse } from "../../../tools/send/models/response/send.response"; import { SendResponse } from "../../../tools/send/models/response/send.response";
import { SendApiService } from "../../../tools/send/services/send-api.service.abstraction"; import { SendApiService } from "../../../tools/send/services/send-api.service.abstraction";
@@ -40,7 +35,6 @@ import { InternalSendService } from "../../../tools/send/services/send.service.a
import { CipherService } from "../../../vault/abstractions/cipher.service"; import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderApiServiceAbstraction } from "../../../vault/abstractions/folder/folder-api.service.abstraction"; import { FolderApiServiceAbstraction } from "../../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../../vault/abstractions/folder/folder.service.abstraction"; import { InternalFolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SyncService as SyncServiceAbstraction } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { CipherData } from "../../../vault/models/data/cipher.data"; import { CipherData } from "../../../vault/models/data/cipher.data";
import { FolderData } from "../../../vault/models/data/folder.data"; import { FolderData } from "../../../vault/models/data/folder.data";
import { CipherResponse } from "../../../vault/models/response/cipher.response"; import { CipherResponse } from "../../../vault/models/response/cipher.response";
@@ -49,55 +43,51 @@ import { CollectionService } from "../../abstractions/collection.service";
import { CollectionData } from "../../models/data/collection.data"; import { CollectionData } from "../../models/data/collection.data";
import { CollectionDetailsResponse } from "../../models/response/collection.response"; import { CollectionDetailsResponse } from "../../models/response/collection.response";
export class SyncService implements SyncServiceAbstraction { export class SyncService extends CoreSyncService {
syncInProgress = false;
constructor( constructor(
private masterPasswordService: InternalMasterPasswordServiceAbstraction, private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private accountService: AccountService, accountService: AccountService,
private apiService: ApiService, apiService: ApiService,
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
private folderService: InternalFolderService, folderService: InternalFolderService,
private cipherService: CipherService, cipherService: CipherService,
private cryptoService: CryptoService, private cryptoService: CryptoService,
private collectionService: CollectionService, collectionService: CollectionService,
private messagingService: MessagingService, messageSender: MessageSender,
private policyService: InternalPolicyService, private policyService: InternalPolicyService,
private sendService: InternalSendService, sendService: InternalSendService,
private logService: LogService, logService: LogService,
private keyConnectorService: KeyConnectorService, private keyConnectorService: KeyConnectorService,
private stateService: StateService, stateService: StateService,
private providerService: ProviderService, private providerService: ProviderService,
private folderApiService: FolderApiServiceAbstraction, folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction,
private sendApiService: SendApiService, sendApiService: SendApiService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private avatarService: AvatarService, private avatarService: AvatarService,
private logoutCallback: (expired: boolean) => Promise<void>, private logoutCallback: (expired: boolean) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService, private billingAccountProfileStateService: BillingAccountProfileStateService,
private tokenService: TokenService, private tokenService: TokenService,
private authService: AuthService, authService: AuthService,
) {} ) {
super(
async getLastSync(): Promise<Date> { stateService,
if ((await this.stateService.getUserId()) == null) { folderService,
return null; folderApiService,
} messageSender,
logService,
const lastSync = await this.stateService.getLastSync(); cipherService,
if (lastSync) { collectionService,
return new Date(lastSync); apiService,
} accountService,
authService,
return null; sendService,
} sendApiService,
);
async setLastSync(date: Date, userId?: string): Promise<any> {
await this.stateService.setLastSync(date.toJSON(), { userId: userId });
} }
@sequentialize(() => "fullSync") @sequentialize(() => "fullSync")
async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> { override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> {
this.syncStarted(); this.syncStarted();
const isAuthenticated = await this.stateService.getIsAuthenticated(); const isAuthenticated = await this.stateService.getIsAuthenticated();
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -110,6 +100,7 @@ export class SyncService implements SyncServiceAbstraction {
needsSync = await this.needsSyncing(forceSync); needsSync = await this.needsSyncing(forceSync);
} catch (e) { } catch (e) {
if (allowThrowOnError) { if (allowThrowOnError) {
this.syncCompleted(false);
throw e; throw e;
} }
} }
@@ -135,6 +126,7 @@ export class SyncService implements SyncServiceAbstraction {
return this.syncCompleted(true); return this.syncCompleted(true);
} catch (e) { } catch (e) {
if (allowThrowOnError) { if (allowThrowOnError) {
this.syncCompleted(false);
throw e; throw e;
} else { } else {
return this.syncCompleted(false); return this.syncCompleted(false);
@@ -142,171 +134,6 @@ export class SyncService implements SyncServiceAbstraction {
} }
} }
async syncUpsertFolder(notification: SyncFolderNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
try {
const localFolder = await this.folderService.get(notification.id);
if (
(!isEdit && localFolder == null) ||
(isEdit && localFolder != null && localFolder.revisionDate < notification.revisionDate)
) {
const remoteFolder = await this.folderApiService.get(notification.id);
if (remoteFolder != null) {
await this.folderService.upsert(new FolderData(remoteFolder));
this.messagingService.send("syncedUpsertedFolder", { folderId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
this.logService.error(e);
}
}
return this.syncCompleted(false);
}
async syncDeleteFolder(notification: SyncFolderNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.folderService.delete(notification.id);
this.messagingService.send("syncedDeletedFolder", { folderId: notification.id });
this.syncCompleted(true);
return true;
}
return this.syncCompleted(false);
}
async syncUpsertCipher(notification: SyncCipherNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
try {
let shouldUpdate = true;
const localCipher = await this.cipherService.get(notification.id);
if (localCipher != null && localCipher.revisionDate >= notification.revisionDate) {
shouldUpdate = false;
}
let checkCollections = false;
if (shouldUpdate) {
if (isEdit) {
shouldUpdate = localCipher != null;
checkCollections = true;
} else {
if (notification.collectionIds == null || notification.organizationId == null) {
shouldUpdate = localCipher == null;
} else {
shouldUpdate = false;
checkCollections = true;
}
}
}
if (
!shouldUpdate &&
checkCollections &&
notification.organizationId != null &&
notification.collectionIds != null &&
notification.collectionIds.length > 0
) {
const collections = await this.collectionService.getAll();
if (collections != null) {
for (let i = 0; i < collections.length; i++) {
if (notification.collectionIds.indexOf(collections[i].id) > -1) {
shouldUpdate = true;
break;
}
}
}
}
if (shouldUpdate) {
const remoteCipher = await this.apiService.getFullCipherDetails(notification.id);
if (remoteCipher != null) {
await this.cipherService.upsert(new CipherData(remoteCipher));
this.messagingService.send("syncedUpsertedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
if (e != null && e.statusCode === 404 && isEdit) {
await this.cipherService.delete(notification.id);
this.messagingService.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
}
}
return this.syncCompleted(false);
}
async syncDeleteCipher(notification: SyncCipherNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.cipherService.delete(notification.id);
this.messagingService.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
return this.syncCompleted(false);
}
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
const [activeUserId, status] = await firstValueFrom(
this.accountService.activeAccount$.pipe(
switchMap((a) => {
if (a == null) {
of([null, AuthenticationStatus.LoggedOut]);
}
return this.authService.authStatusFor$(a.id).pipe(map((s) => [a.id, s]));
}),
),
);
// Process only notifications for currently active user when user is not logged out
// TODO: once send service allows data manipulation of non-active users, this should process any received notification
if (activeUserId === notification.userId && status !== AuthenticationStatus.LoggedOut) {
try {
const localSend = await firstValueFrom(this.sendService.get$(notification.id));
if (
(!isEdit && localSend == null) ||
(isEdit && localSend != null && localSend.revisionDate < notification.revisionDate)
) {
const remoteSend = await this.sendApiService.getSend(notification.id);
if (remoteSend != null) {
await this.sendService.upsert(new SendData(remoteSend));
this.messagingService.send("syncedUpsertedSend", { sendId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
this.logService.error(e);
}
}
return this.syncCompleted(false);
}
async syncDeleteSend(notification: SyncSendNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.sendService.delete(notification.id);
this.messagingService.send("syncedDeletedSend", { sendId: notification.id });
this.syncCompleted(true);
return true;
}
return this.syncCompleted(false);
}
// Helpers
private syncStarted() {
this.syncInProgress = true;
this.messagingService.send("syncStarted");
}
private syncCompleted(successfully: boolean): boolean {
this.syncInProgress = false;
this.messagingService.send("syncCompleted", { successfully: successfully });
return successfully;
}
private async needsSyncing(forceSync: boolean) { private async needsSyncing(forceSync: boolean) {
if (forceSync) { if (forceSync) {
return true; return true;
@@ -365,7 +192,7 @@ export class SyncService implements SyncServiceAbstraction {
if (await this.keyConnectorService.userNeedsMigration()) { if (await this.keyConnectorService.userNeedsMigration()) {
await this.keyConnectorService.setConvertAccountRequired(true); await this.keyConnectorService.setConvertAccountRequired(true);
this.messagingService.send("convertAccountToKeyConnector"); this.messageSender.send("convertAccountToKeyConnector");
} else { } else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@@ -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 { BehaviorSubject, map } from "rxjs";
import { NavBaseComponent } from "./nav-base.component"; import { NavBaseComponent } from "./nav-base.component";
@@ -10,6 +10,9 @@ import { NavGroupComponent } from "./nav-group.component";
providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }], providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }],
}) })
export class NavItemComponent extends NavBaseComponent { 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 * Is `true` if `to` matches the current route
*/ */
@@ -21,7 +24,7 @@ export class NavItemComponent extends NavBaseComponent {
} }
} }
protected get showActiveStyles() { 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>
`,
}),
};