Merge branch 'main' into SM-1192-SecretsNames
This commit is contained in:
commit
36878acae4
|
@ -380,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();
|
||||||
|
@ -399,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,
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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> {
|
||||||
|
try {
|
||||||
await chrome.offscreen.createDocument({
|
await chrome.offscreen.createDocument({
|
||||||
url: "offscreen-document/index.html",
|
url: "offscreen-document/index.html",
|
||||||
reasons,
|
reasons,
|
||||||
justification,
|
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> {
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
*ngIf="showBackButton"
|
*ngIf="showBackButton"
|
||||||
[title]="'back' | i18n"
|
[title]="'back' | i18n"
|
||||||
[ariaLabel]="'back' | i18n"
|
[ariaLabel]="'back' | i18n"
|
||||||
|
(click)="back()"
|
||||||
></button>
|
></button>
|
||||||
<h1 bitTypography="h3" class="!tw-mb-0.5 tw-text-headers">{{ pageTitle }}</h1>
|
<h1 bitTypography="h3" class="!tw-mb-0.5 tw-text-headers">{{ pageTitle }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
<bit-tab label="{{ 'members' | i18n }}">
|
<bit-tab label="{{ 'members' | i18n }}">
|
||||||
<p>
|
<p>
|
||||||
{{ "editGroupMembersDesc" | i18n }}
|
{{ "editGroupMembersDesc" | i18n }}
|
||||||
<span *ngIf="restrictGroupAccess$ | async">
|
<span *ngIf="cannotAddSelfToGroup$ | async">
|
||||||
{{ "restrictedGroupAccessDesc" | i18n }}
|
{{ "restrictedGroupAccessDesc" | i18n }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
@ -52,8 +52,8 @@
|
||||||
<bit-tab label="{{ 'collections' | i18n }}">
|
<bit-tab label="{{ 'collections' | i18n }}">
|
||||||
<p>
|
<p>
|
||||||
{{ "editGroupCollectionsDesc" | i18n }}
|
{{ "editGroupCollectionsDesc" | i18n }}
|
||||||
<span *ngIf="!(allowAdminAccessToAllCollectionItems$ | async)">
|
<span *ngIf="!(canEditAnyCollection$ | async)">
|
||||||
{{ "editGroupCollectionsRestrictionsDesc" | i18n }}
|
{{ "restrictedCollectionAssignmentDesc" | i18n }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div *ngIf="!(flexibleCollectionsEnabled$ | async)" class="tw-my-3">
|
<div *ngIf="!(flexibleCollectionsEnabled$ | async)" class="tw-my-3">
|
||||||
|
|
|
@ -183,7 +183,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
||||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
allowAdminAccessToAllCollectionItems$ = combineLatest([
|
protected allowAdminAccessToAllCollectionItems$ = combineLatest([
|
||||||
this.organization$,
|
this.organization$,
|
||||||
this.flexibleCollectionsV1Enabled$,
|
this.flexibleCollectionsV1Enabled$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
|
@ -196,7 +196,16 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
restrictGroupAccess$ = combineLatest([
|
protected canEditAnyCollection$ = combineLatest([
|
||||||
|
this.organization$,
|
||||||
|
this.flexibleCollectionsV1Enabled$,
|
||||||
|
]).pipe(
|
||||||
|
map(([org, flexibleCollectionsV1Enabled]) =>
|
||||||
|
org.canEditAnyCollection(flexibleCollectionsV1Enabled),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected cannotAddSelfToGroup$ = combineLatest([
|
||||||
this.allowAdminAccessToAllCollectionItems$,
|
this.allowAdminAccessToAllCollectionItems$,
|
||||||
this.groupDetails$,
|
this.groupDetails$,
|
||||||
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
|
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
|
||||||
|
@ -229,7 +238,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
||||||
this.orgCollections$,
|
this.orgCollections$,
|
||||||
this.orgMembers$,
|
this.orgMembers$,
|
||||||
this.groupDetails$,
|
this.groupDetails$,
|
||||||
this.restrictGroupAccess$,
|
this.cannotAddSelfToGroup$,
|
||||||
this.accountService.activeAccount$,
|
this.accountService.activeAccount$,
|
||||||
this.organization$,
|
this.organization$,
|
||||||
this.flexibleCollectionsV1Enabled$,
|
this.flexibleCollectionsV1Enabled$,
|
||||||
|
|
|
@ -405,7 +405,7 @@
|
||||||
<bit-tab *ngIf="organization.useGroups" [label]="'groups' | i18n">
|
<bit-tab *ngIf="organization.useGroups" [label]="'groups' | i18n">
|
||||||
<div class="tw-mb-6">
|
<div class="tw-mb-6">
|
||||||
{{
|
{{
|
||||||
(restrictedAccess$ | async)
|
(restrictEditingSelf$ | async)
|
||||||
? ("restrictedGroupAccess" | i18n)
|
? ("restrictedGroupAccess" | i18n)
|
||||||
: ("groupAccessUserDesc" | i18n)
|
: ("groupAccessUserDesc" | i18n)
|
||||||
}}
|
}}
|
||||||
|
@ -417,15 +417,18 @@
|
||||||
[selectorLabelText]="'selectGroups' | i18n"
|
[selectorLabelText]="'selectGroups' | i18n"
|
||||||
[emptySelectionText]="'noGroupsAdded' | i18n"
|
[emptySelectionText]="'noGroupsAdded' | i18n"
|
||||||
[flexibleCollectionsEnabled]="organization.flexibleCollections"
|
[flexibleCollectionsEnabled]="organization.flexibleCollections"
|
||||||
[hideMultiSelect]="restrictedAccess$ | async"
|
[hideMultiSelect]="restrictEditingSelf$ | async"
|
||||||
></bit-access-selector>
|
></bit-access-selector>
|
||||||
</bit-tab>
|
</bit-tab>
|
||||||
<bit-tab [label]="'collections' | i18n">
|
<bit-tab [label]="'collections' | i18n">
|
||||||
<div class="tw-mb-6" *ngIf="restrictedAccess$ | async">
|
<div class="tw-mb-6" *ngIf="restrictEditingSelf$ | async">
|
||||||
{{ "restrictedCollectionAccess" | i18n }}
|
{{ "cannotAddYourselfToCollections" | i18n }}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="organization.useGroups && !(restrictedAccess$ | async)" class="tw-mb-6">
|
<div *ngIf="organization.useGroups && !(restrictEditingSelf$ | async)" class="tw-mb-6">
|
||||||
{{ "userPermissionOverrideHelper" | i18n }}
|
{{ "userPermissionOverrideHelperDesc" | i18n }}
|
||||||
|
<span *ngIf="!(canEditAnyCollection$ | async)">
|
||||||
|
{{ "restrictedCollectionAssignmentDesc" | i18n }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!organization.flexibleCollections" class="tw-mb-6">
|
<div *ngIf="!organization.flexibleCollections" class="tw-mb-6">
|
||||||
<bit-form-control>
|
<bit-form-control>
|
||||||
|
@ -454,7 +457,7 @@
|
||||||
[selectorLabelText]="'selectCollections' | i18n"
|
[selectorLabelText]="'selectCollections' | i18n"
|
||||||
[emptySelectionText]="'noCollectionsAdded' | i18n"
|
[emptySelectionText]="'noCollectionsAdded' | i18n"
|
||||||
[flexibleCollectionsEnabled]="organization.flexibleCollections"
|
[flexibleCollectionsEnabled]="organization.flexibleCollections"
|
||||||
[hideMultiSelect]="restrictedAccess$ | async"
|
[hideMultiSelect]="restrictEditingSelf$ | async"
|
||||||
></bit-access-selector
|
></bit-access-selector
|
||||||
></bit-tab>
|
></bit-tab>
|
||||||
</bit-tab-group>
|
</bit-tab-group>
|
||||||
|
|
|
@ -105,7 +105,9 @@ export class MemberDialogComponent implements OnDestroy {
|
||||||
groups: [[] as AccessItemValue[]],
|
groups: [[] as AccessItemValue[]],
|
||||||
});
|
});
|
||||||
|
|
||||||
protected restrictedAccess$: Observable<boolean>;
|
protected allowAdminAccessToAllCollectionItems$: Observable<boolean>;
|
||||||
|
protected restrictEditingSelf$: Observable<boolean>;
|
||||||
|
protected canEditAnyCollection$: Observable<boolean>;
|
||||||
|
|
||||||
protected permissionsGroup = this.formBuilder.group({
|
protected permissionsGroup = this.formBuilder.group({
|
||||||
manageAssignedCollectionsGroup: this.formBuilder.group<Record<string, boolean>>({
|
manageAssignedCollectionsGroup: this.formBuilder.group<Record<string, boolean>>({
|
||||||
|
@ -182,43 +184,59 @@ export class MemberDialogComponent implements OnDestroy {
|
||||||
? this.userService.get(this.params.organizationId, this.params.organizationUserId)
|
? this.userService.get(this.params.organizationId, this.params.organizationUserId)
|
||||||
: of(null);
|
: of(null);
|
||||||
|
|
||||||
// The orgUser cannot manage their own Group assignments if collection access is restricted
|
this.allowAdminAccessToAllCollectionItems$ = combineLatest([
|
||||||
// TODO: fix disabled state of access-selector rows so that any controls are hidden
|
|
||||||
this.restrictedAccess$ = combineLatest([
|
|
||||||
this.organization$,
|
this.organization$,
|
||||||
userDetails$,
|
|
||||||
this.accountService.activeAccount$,
|
|
||||||
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
|
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
|
||||||
|
]).pipe(
|
||||||
|
map(([organization, flexibleCollectionsV1Enabled]) => {
|
||||||
|
if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return organization.allowAdminAccessToAllCollectionItems;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The orgUser cannot manage their own Group assignments if collection access is restricted
|
||||||
|
this.restrictEditingSelf$ = combineLatest([
|
||||||
|
this.allowAdminAccessToAllCollectionItems$,
|
||||||
|
userDetails$,
|
||||||
|
this.accountService.activeAccount$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(
|
map(
|
||||||
([organization, userDetails, activeAccount, flexibleCollectionsV1Enabled]) =>
|
([allowAdminAccess, userDetails, activeAccount]) =>
|
||||||
// Feature flag conditionals
|
!allowAdminAccess && userDetails != null && userDetails.userId == activeAccount.id,
|
||||||
flexibleCollectionsV1Enabled &&
|
|
||||||
organization.flexibleCollections &&
|
|
||||||
// Business logic conditionals
|
|
||||||
userDetails != null &&
|
|
||||||
userDetails.userId == activeAccount.id &&
|
|
||||||
!organization.allowAdminAccessToAllCollectionItems,
|
|
||||||
),
|
),
|
||||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.restrictedAccess$.pipe(takeUntil(this.destroy$)).subscribe((restrictedAccess) => {
|
this.restrictEditingSelf$.pipe(takeUntil(this.destroy$)).subscribe((restrictEditingSelf) => {
|
||||||
if (restrictedAccess) {
|
if (restrictEditingSelf) {
|
||||||
this.formGroup.controls.groups.disable();
|
this.formGroup.controls.groups.disable();
|
||||||
} else {
|
} else {
|
||||||
this.formGroup.controls.groups.enable();
|
this.formGroup.controls.groups.enable();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.FlexibleCollectionsV1,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.canEditAnyCollection$ = combineLatest([
|
||||||
|
this.organization$,
|
||||||
|
flexibleCollectionsV1Enabled$,
|
||||||
|
]).pipe(
|
||||||
|
map(([org, flexibleCollectionsV1Enabled]) =>
|
||||||
|
org.canEditAnyCollection(flexibleCollectionsV1Enabled),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
combineLatest({
|
combineLatest({
|
||||||
organization: this.organization$,
|
organization: this.organization$,
|
||||||
collections: this.collectionAdminService.getAll(this.params.organizationId),
|
collections: this.collectionAdminService.getAll(this.params.organizationId),
|
||||||
userDetails: userDetails$,
|
userDetails: userDetails$,
|
||||||
groups: groups$,
|
groups: groups$,
|
||||||
flexibleCollectionsV1Enabled: this.configService.getFeatureFlag$(
|
flexibleCollectionsV1Enabled: flexibleCollectionsV1Enabled$,
|
||||||
FeatureFlag.FlexibleCollectionsV1,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
|
@ -454,7 +472,7 @@ export class MemberDialogComponent implements OnDestroy {
|
||||||
.filter((v) => v.type === AccessItemType.Collection)
|
.filter((v) => v.type === AccessItemType.Collection)
|
||||||
.map(convertToSelectionView);
|
.map(convertToSelectionView);
|
||||||
|
|
||||||
userView.groups = (await firstValueFrom(this.restrictedAccess$))
|
userView.groups = (await firstValueFrom(this.restrictEditingSelf$))
|
||||||
? null
|
? null
|
||||||
: this.formGroup.value.groups.map((m) => m.id);
|
: this.formGroup.value.groups.map((m) => m.id);
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||||
import { Component, Inject, ViewChild } from "@angular/core";
|
import { Component, Inject, ViewChild } from "@angular/core";
|
||||||
import { FormGroup } from "@angular/forms";
|
import { FormGroup } from "@angular/forms";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||||
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
|
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
|
||||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||||
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
|
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
|
||||||
import { DialogService } from "@bitwarden/components";
|
|
||||||
|
|
||||||
import { PaymentComponent } from "./payment.component";
|
import { PaymentComponent } from "./payment.component";
|
||||||
import { TaxInfoComponent } from "./tax-info.component";
|
import { TaxInfoComponent } from "./tax-info.component";
|
||||||
|
@ -44,10 +45,10 @@ export class AdjustPaymentDialogComponent {
|
||||||
@Inject(DIALOG_DATA) protected data: AdjustPaymentDialogData,
|
@Inject(DIALOG_DATA) protected data: AdjustPaymentDialogData,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
|
||||||
private logService: LogService,
|
|
||||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||||
private paymentMethodWarningService: PaymentMethodWarningService,
|
private paymentMethodWarningService: PaymentMethodWarningService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private toastService: ToastService,
|
||||||
) {
|
) {
|
||||||
this.organizationId = data.organizationId;
|
this.organizationId = data.organizationId;
|
||||||
this.currentType = data.currentType;
|
this.currentType = data.currentType;
|
||||||
|
@ -73,14 +74,17 @@ export class AdjustPaymentDialogComponent {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await response;
|
await response;
|
||||||
if (this.organizationId) {
|
const showPaymentMethodWarningBanners = await firstValueFrom(
|
||||||
|
this.configService.getFeatureFlag$(FeatureFlag.ShowPaymentMethodWarningBanners),
|
||||||
|
);
|
||||||
|
if (this.organizationId && showPaymentMethodWarningBanners) {
|
||||||
await this.paymentMethodWarningService.removeSubscriptionRisk(this.organizationId);
|
await this.paymentMethodWarningService.removeSubscriptionRisk(this.organizationId);
|
||||||
}
|
}
|
||||||
this.platformUtilsService.showToast(
|
this.toastService.showToast({
|
||||||
"success",
|
variant: "success",
|
||||||
null,
|
title: null,
|
||||||
this.i18nService.t("updatedPaymentMethod"),
|
message: this.i18nService.t("updatedPaymentMethod"),
|
||||||
);
|
});
|
||||||
this.dialogRef.close(AdjustPaymentDialogResult.Adjusted);
|
this.dialogRef.close(AdjustPaymentDialogResult.Adjusted);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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))),
|
||||||
|
);
|
||||||
|
}
|
|
@ -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[],
|
||||||
|
},
|
||||||
|
};
|
|
@ -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,
|
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {}
|
||||||
|
|
|
@ -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,9 +124,12 @@ export default {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
} as Meta;
|
} as Meta<ProductSwitcherComponent>;
|
||||||
|
|
||||||
const Template: Story = (args) => ({
|
type Story = StoryObj<ProductSwitcherComponent & MockProviderService & MockOrganizationService>;
|
||||||
|
|
||||||
|
const Template: Story = {
|
||||||
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
template: `
|
template: `
|
||||||
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
|
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
|
||||||
|
@ -142,28 +149,42 @@ const Template: Story = (args) => ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
}),
|
||||||
|
};
|
||||||
export const OnlyPM = Template.bind({});
|
export const OnlyPM: Story = {
|
||||||
OnlyPM.args = {
|
...Template,
|
||||||
|
args: {
|
||||||
mockOrgs: [],
|
mockOrgs: [],
|
||||||
mockProviders: [],
|
mockProviders: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithSM = Template.bind({});
|
export const WithSM: Story = {
|
||||||
WithSM.args = {
|
...Template,
|
||||||
mockOrgs: [{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true }],
|
args: {
|
||||||
|
mockOrgs: [
|
||||||
|
{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true },
|
||||||
|
] as Organization[],
|
||||||
mockProviders: [],
|
mockProviders: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithSMAndAC = Template.bind({});
|
export const WithSMAndAC: Story = {
|
||||||
WithSMAndAC.args = {
|
...Template,
|
||||||
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }],
|
args: {
|
||||||
|
mockOrgs: [
|
||||||
|
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
|
||||||
|
] as Organization[],
|
||||||
mockProviders: [],
|
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[],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -95,7 +105,7 @@
|
||||||
[items]="accessItems"
|
[items]="accessItems"
|
||||||
[columnHeader]="'groupSlashMemberColumnHeader' | i18n"
|
[columnHeader]="'groupSlashMemberColumnHeader' | i18n"
|
||||||
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
|
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
|
||||||
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
|
[selectorHelpText]="'userPermissionOverrideHelperDesc' | i18n"
|
||||||
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
|
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
|
||||||
[flexibleCollectionsEnabled]="organization.flexibleCollections"
|
[flexibleCollectionsEnabled]="organization.flexibleCollections"
|
||||||
></bit-access-selector>
|
></bit-access-selector>
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
[items]="accessItems"
|
[items]="accessItems"
|
||||||
[columnHeader]="'groupSlashMemberColumnHeader' | i18n"
|
[columnHeader]="'groupSlashMemberColumnHeader' | i18n"
|
||||||
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
|
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
|
||||||
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
|
[selectorHelpText]="'userPermissionOverrideHelperDesc' | i18n"
|
||||||
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
|
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
|
||||||
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
|
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
|
||||||
></bit-access-selector>
|
></bit-access-selector>
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -6588,7 +6588,7 @@
|
||||||
"editGroupCollectionsDesc": {
|
"editGroupCollectionsDesc": {
|
||||||
"message": "Grant access to collections by adding them to this group."
|
"message": "Grant access to collections by adding them to this group."
|
||||||
},
|
},
|
||||||
"editGroupCollectionsRestrictionsDesc": {
|
"restrictedCollectionAssignmentDesc": {
|
||||||
"message": "You can only assign collections you manage."
|
"message": "You can only assign collections you manage."
|
||||||
},
|
},
|
||||||
"accessAllCollectionsDesc": {
|
"accessAllCollectionsDesc": {
|
||||||
|
@ -6822,8 +6822,8 @@
|
||||||
"selectGroups": {
|
"selectGroups": {
|
||||||
"message": "Select groups"
|
"message": "Select groups"
|
||||||
},
|
},
|
||||||
"userPermissionOverrideHelper": {
|
"userPermissionOverrideHelperDesc": {
|
||||||
"message": "Permissions set for a member will replace permissions set by that member's group"
|
"message": "Permissions set for a member will replace permissions set by that member's group."
|
||||||
},
|
},
|
||||||
"noMembersOrGroupsAdded": {
|
"noMembersOrGroupsAdded": {
|
||||||
"message": "No members or groups added"
|
"message": "No members or groups added"
|
||||||
|
@ -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."
|
||||||
},
|
},
|
||||||
|
@ -7743,7 +7749,7 @@
|
||||||
"restrictedGroupAccess": {
|
"restrictedGroupAccess": {
|
||||||
"message": "You cannot add yourself to groups."
|
"message": "You cannot add yourself to groups."
|
||||||
},
|
},
|
||||||
"restrictedCollectionAccess": {
|
"cannotAddYourselfToCollections": {
|
||||||
"message": "You cannot add yourself to collections."
|
"message": "You cannot add yourself to collections."
|
||||||
},
|
},
|
||||||
"assign": {
|
"assign": {
|
||||||
|
@ -8210,5 +8216,11 @@
|
||||||
"example": "2"
|
"example": "2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"secureYourInfrastructure": {
|
||||||
|
"message": "Secure your infrastructure"
|
||||||
|
},
|
||||||
|
"protectYourFamilyOrBusiness": {
|
||||||
|
"message": "Protect your family or business"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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],
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,20 +19,24 @@ 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);
|
||||||
|
return new SymmetricCryptoKey(decValue) as OrgKey;
|
||||||
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);
|
get encryptedOrganizationKey() {
|
||||||
return new SymmetricCryptoKey(decValue) as OrgKey;
|
return new EncString(this.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
toData(): EncryptedOrganizationKeyData {
|
toData(): EncryptedOrganizationKeyData {
|
||||||
|
@ -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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
zip(
|
||||||
this.activeUserEncryptedProviderKeysState.state$.pipe(filter((keys) => keys != null)),
|
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) {
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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"],
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
|
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
|
||||||
</bit-callout>
|
</bit-callout>
|
||||||
<form [formGroup]="formGroup" [bitSubmit]="submit" id="import_form_importForm">
|
<form [formGroup]="formGroup" [bitSubmit]="submit" id="import_form_importForm">
|
||||||
<bit-form-field>
|
<bit-form-field [hidden]="isFromAC">
|
||||||
<bit-label
|
<bit-label
|
||||||
>{{ "importDestination" | i18n }}
|
>{{ "importDestination" | i18n }}
|
||||||
<a
|
<a
|
||||||
|
|
|
@ -132,7 +132,7 @@ export class ImportComponent implements OnInit, OnDestroy {
|
||||||
protected destroy$ = new Subject<void>();
|
protected destroy$ = new Subject<void>();
|
||||||
|
|
||||||
private _importBlockedByPolicy = false;
|
private _importBlockedByPolicy = false;
|
||||||
private _isFromAC = false;
|
protected isFromAC = false;
|
||||||
|
|
||||||
formGroup = this.formBuilder.group({
|
formGroup = this.formBuilder.group({
|
||||||
vaultSelector: [
|
vaultSelector: [
|
||||||
|
@ -232,7 +232,7 @@ export class ImportComponent implements OnInit, OnDestroy {
|
||||||
.then((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
|
.then((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
|
||||||
);
|
);
|
||||||
|
|
||||||
this._isFromAC = true;
|
this.isFromAC = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleImportInit() {
|
private handleImportInit() {
|
||||||
|
@ -359,7 +359,7 @@ export class ImportComponent implements OnInit, OnDestroy {
|
||||||
importContents,
|
importContents,
|
||||||
this.organizationId,
|
this.organizationId,
|
||||||
this.formGroup.controls.targetSelector.value,
|
this.formGroup.controls.targetSelector.value,
|
||||||
(await this.canAccessImportExport(this.organizationId)) && this._isFromAC,
|
(await this.canAccessImportExport(this.organizationId)) && this.isFromAC,
|
||||||
);
|
);
|
||||||
|
|
||||||
//No errors, display success message
|
//No errors, display success message
|
||||||
|
|
Loading…
Reference in New Issue