mirror of
https://github.com/bitwarden/browser
synced 2026-01-04 17:43:39 +00:00
[AC-1011] Admin Console / Billing code ownership (#4973)
* refactor: move SCIM component to admin-console, refs EC-1011 * refactor: move scimProviderType to admin-console, refs EC-1011 * refactor: move scim-config.api to admin-console, refs EC-1011 * refactor: create models folder and nest existing api contents, refs EC-1011 * refactor: move scim-config to admin-console models, refs EC-1011 * refactor: move billing.component to billing, refs EC-1011 * refactor: remove nested app folder from new billing structure, refs EC-1011 * refactor: move organizations/billing to billing, refs EC-1011 * refactor: move add-credit and adjust-payment to billing/settings, refs EC-1011 * refactor: billing history/sync to billing, refs EC-1011 * refactor: move org plans, payment/method to billing/settings, refs EC-1011 * fix: update legacy file paths for payment-method and tax-info, refs EC-1011 * fix: update imports for scim component, refs EC-1011 * refactor: move subscription and tax-info into billing, refs EC-1011 * refactor: move user-subscription to billing, refs EC-1011 * refactor: move images/cards to billing and update base path, refs EC-1011 * refactor: move payment-method, plan subscription, and plan to billing, refs EC-1011 * refactor: move transaction-type to billing, refs EC-1011 * refactor: move billing-sync-config to billing, refs EC-1011 * refactor: move billing-sync and bit-pay-invoice request to billing, refs EC-1011 * refactor: move org subscription and tax info update requests to billing, refs EC-1011 * fix: broken paths to billing, refs EC-1011 * refactor: move payment request to billing, refs EC-1011 * fix: update remaining imports for payment-request, refs EC-1011 * refactor: move tax-info-update to billing, refs EC-1011 * refactor: move billing-payment, billing-history, and billing responses to billing, refs EC-1011 * refactor: move organization-subscription-responset to billing, refs EC-1011 * refactor: move payment and plan responses to billing, refs EC-1011 * refactor: move subscription response to billing ,refs EC-1011 * refactor: move tax info and rate responses to billing, refs EC-1011 * fix: update remaining path to base response for tax-rate response, refs EC-1011 * refactor: (browser) move organization-service to admin-console, refs EC-1011 * refactor: (browser) move organizaiton-service to admin-console, refs EC-1011 * refactor: (cli) move share command to admin-console, refs EC-1011 * refactor: move organization-collect request model to admin-console, refs EC-1011 * refactor: (web) move organization, collection/user responses to admin-console, refs EC-1011 * refactor: (cli) move selection-read-only to admin-console, refs EC-1011 * refactor: (desktop) move organization-filter to admin-console, refs EC-1011 * refactor: (web) move organization-switcher to admin-console, refs EC-1011 * refactor: (web) move access-selector to admin-console, refs EC-1011 * refactor: (web) move create folder to admin-console, refs EC-1011 * refactor: (web) move org guards folder to admin-console, refs EC-1011 * refactor: (web) move org layout to admin-console, refs EC-1011 * refactor: move manage collections to admin console, refs EC-1011 * refactor: (web) move collection-dialog to admin-console, refs EC-1011 * refactor: (web) move entity users/events and events component to admin-console, refs EC-1011 * refactor: (web) move groups/group-add-edit to admin-console, refs EC-1011 * refactor: (web) move manage, org-manage module, and user-confirm to admin-console, refs EC-1011 * refactor: (web) move people to admin-console, refs EC-1011 * refactor: (web) move reset-password to admin-console, refs EC-1011 * refactor: (web) move organization-routing and module to admin-console, refs EC-1011 * refactor: move admin-console and billing within app scope, refs EC-1011 * fix: update leftover merge conflicts, refs EC-1011 * refactor: (web) member-dialog to admin-console, refs EC-1011 * refactor: (web) move policies to admin-console, refs EC-1011 * refactor: (web) move reporting to admin-console, refs EC-1011 * refactor: (web) move settings to admin-console, refs EC-1011 * refactor: (web) move sponsorships to admin-console, refs EC-1011 * refactor: (web) move tools to admin-console, refs EC-1011 * refactor: (web) move users to admin-console, refs EC-1011 * refactor: (web) move collections to admin-console, refs EC-1011 * refactor: (web) move create-organization to admin-console, refs EC-1011 * refactor: (web) move licensed components to admin-console, refs EC-1011 * refactor: (web) move bit organization modules to admin-console, refs EC-1011 * fix: update leftover import statements for organizations.module, refs EC-1011 * refactor: (web) move personal vault and max timeout to admin-console, refs EC-1011 * refactor: (web) move providers to admin-console, refs EC-1011 * refactor: (libs) move organization service to admin-console, refs EC-1011 * refactor: (libs) move profile org/provider responses and other misc org responses to admin-console, refs EC-1011 * refactor: (libs) move provider request and selectionion-read-only request to admin-console, refs EC-1011 * fix: update missed import path for provider-user-update request, refs EC-1011 * refactor: (libs) move abstractions to admin-console, refs EC-1011 * refactor: (libs) move org/provider enums to admin-console, refs EC-1011 * fix: update downstream import statements from libs changes, refs EC-1011 * refactor: (libs) move data files to admin-console, refs EC-1011 * refactor: (libs) move domain to admin-console, refs EC-1011 * refactor: (libs) move request objects to admin-console, refs EC-1011 * fix: update downstream import changes from libs, refs EC-1011 * refactor: move leftover provider files to admin-console, refs EC-1011 * refactor: (browser) move group policy environment to admin-console, refs EC-1011 * fix: (browser) update downstream import statements, refs EC-1011 * fix: (desktop) update downstream libs moves, refs EC-1011 * fix: (cli) update downstream import changes from libs, refs EC-1011 * refactor: move org-auth related files to admin-console, refs EC-1011 * refactor: (libs) move request objects to admin-console, refs EC-1011 * refactor: move persmissions to admin-console, refs EC-1011 * refactor: move sponsored families to admin-console and fix libs changes, refs EC-1011 * refactor: move collections to admin-console, refs EC-1011 * refactor: move spec file back to spec scope, refs EC-1011 * fix: update downstream imports due to libs changes, refs EC-1011 * fix: udpate downstream import changes due to libs, refs EC-1011 * fix: update downstream imports due to libs changes, refs EC-1011 * fix: update downstream imports from libs changes, refs EC-1011 * fix: update path malformation in jslib-services.module, refs EC-1011 * fix: lint errors from improper casing, refs AC-1011 * fix: update downstream filename changes, refs AC-1011 * fix: (cli) update downstream filename changes, refs AC-1011 * fix: (desktop) update downstream filename changes, refs AC-1011 * fix: (browser) update downstream filename changes, refs AC-1011 * fix: lint errors, refs AC-1011 * fix: prettier, refs AC-1011 * fix: lint fixes for import order, refs AC-1011 * fix: update import path for provider user type, refs AC-1011 * fix: update new codes import paths for admin console structure, refs AC-1011 * fix: lint/prettier, refs AC-1011 * fix: update layout stories path, refs AC-1011 * fix: update comoponents card icons base variable in styles, refs AC-1011 * fix: update provider service path in permissions guard spec, refs AC-1011 * fix: update provider permission guard path, refs AC-1011 * fix: remove unecessary TODO for shared index export statement, refs AC-1011 * refactor: move browser-organization service and cli organization-user response out of admin-console, refs AC-1011 * refactor: move web/browser/desktop collections component to vault domain, refs AC-1011 * refactor: move organization.module out of admin-console scope, refs AC-1011 * fix: prettier, refs AC-1011 * refactor: move organizations-api-key.request out of admin-console scope, refs AC-1011
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
<!-- Please remove this disable statement when editing this file! -->
|
||||
<!-- eslint-disable tailwindcss/no-custom-classname -->
|
||||
<div class="tw-flex">
|
||||
<bit-form-field *ngIf="permissionMode == 'edit'" class="tw-mr-3 tw-shrink-0">
|
||||
<bit-label>{{ "permission" | i18n }}</bit-label>
|
||||
<!--
|
||||
Built-in select height differs between browsers, this fix makes sure we match bit-multi-select height.
|
||||
We might want to reconsider this fix when/if we implement
|
||||
[CL-78] [Improvement] Completely restyled selects (https://bitwarden.atlassian.net/browse/CL-78)
|
||||
-->
|
||||
<select
|
||||
class="tw-h-[35px]"
|
||||
bitInput
|
||||
[disabled]="disabled"
|
||||
[(ngModel)]="initialPermission"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(blur)="handleBlur()"
|
||||
>
|
||||
<option *ngFor="let p of permissionList" [value]="p.perm">
|
||||
{{ p.labelId | i18n }}
|
||||
</option>
|
||||
</select>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field class="tw-grow">
|
||||
<bit-label>{{ selectorLabelText }}</bit-label>
|
||||
<bit-multi-select
|
||||
class="tw-w-full"
|
||||
[baseItems]="selectionList.deselectedItems"
|
||||
[removeSelectedItems]="true"
|
||||
[disabled]="disabled"
|
||||
(onItemsConfirmed)="selectItems($event)"
|
||||
(blur)="handleBlur()"
|
||||
></bit-multi-select>
|
||||
<bit-hint *ngIf="selectorHelpText">{{ selectorHelpText }}</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
<bit-table [formGroup]="formGroup">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>{{ columnHeader }}</th>
|
||||
<th bitCell id="permissionColHeading" *ngIf="permissionMode != 'hidden'">
|
||||
<div class="tw-border tw-border-solid tw-border-transparent">
|
||||
{{ "permission" | i18n }}
|
||||
</div>
|
||||
</th>
|
||||
<th bitCell id="roleColHeading" *ngIf="showMemberRoles">{{ "role" | i18n }}</th>
|
||||
<th bitCell id="groupColHeading" *ngIf="showGroupColumn">{{ "group" | i18n }}</th>
|
||||
<th bitCell class="tw-w-20"></th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body formArrayName="items">
|
||||
<tr
|
||||
bitRow
|
||||
*ngFor="let item of selectionList.selectedItems; let i = index"
|
||||
[formGroupName]="i"
|
||||
[ngClass]="{ 'tw-text-muted': item.readonly }"
|
||||
>
|
||||
<td bitCell [ngSwitch]="item.type">
|
||||
<div class="tw-flex tw-items-center" *ngSwitchCase="itemType.Member">
|
||||
<bit-avatar size="small" class="tw-mr-3" text="{{ item.labelName }}"></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div>
|
||||
{{ item.labelName }}
|
||||
<span *ngIf="$any(item).status == 0" bitBadge badgeType="secondary">
|
||||
{{ "invited" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw-text-xs tw-text-muted" *ngIf="$any(item).status != 0">
|
||||
{{ $any(item).email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center" *ngSwitchDefault>
|
||||
<i
|
||||
class="bwi tw-mr-3 tw-px-0.5 tw-text-2xl"
|
||||
[ngClass]="item.icon || itemIcon(item)"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span>{{ item.labelName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td bitCell *ngIf="permissionMode != 'hidden'">
|
||||
<ng-container *ngIf="canEditItemPermission(item); else readOnlyPerm">
|
||||
<label class="sr-only" [for]="'permission' + i"
|
||||
>{{ item.labelName }} {{ "permission" | i18n }}</label
|
||||
>
|
||||
<div class="tw-relative tw-inline-block">
|
||||
<select
|
||||
bitInput
|
||||
class="tw-apperance-none -tw-ml-3 tw-max-w-40 tw-appearance-none tw-overflow-ellipsis !tw-rounded tw-border-transparent !tw-bg-transparent tw-pr-6 tw-font-bold hover:tw-border-primary-700"
|
||||
formControlName="permission"
|
||||
[id]="'permission' + i"
|
||||
(blur)="handleBlur()"
|
||||
>
|
||||
<option *ngFor="let p of permissionList" [value]="p.perm">
|
||||
{{ p.labelId | i18n }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
[for]="'permission' + i"
|
||||
class="tw-absolute tw-inset-y-0 tw-right-4 tw-mb-0 tw-flex tw-items-center"
|
||||
>
|
||||
<i class="bwi bwi-sm bwi-angle-down tw-leading-[0]"></i>
|
||||
</label>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #readOnlyPerm>
|
||||
<div
|
||||
*ngIf="item.accessAllItems"
|
||||
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-border tw-border-solid tw-border-transparent tw-font-bold tw-text-muted"
|
||||
[appA11yTitle]="accessAllLabelId(item) | i18n"
|
||||
>
|
||||
{{ "canEdit" | i18n }}
|
||||
<i class="bwi bwi-filter tw-ml-1" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="item.readonly"
|
||||
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
|
||||
[title]="permissionLabelId(item.readonlyPermission) | i18n"
|
||||
>
|
||||
{{ permissionLabelId(item.readonlyPermission) | i18n }}
|
||||
</div>
|
||||
</ng-template>
|
||||
</td>
|
||||
|
||||
<td bitCell *ngIf="showMemberRoles">
|
||||
{{ $any(item).role | userType : "-" }}
|
||||
</td>
|
||||
|
||||
<td bitCell *ngIf="showGroupColumn">
|
||||
{{ $any(item).viaGroupName ?? "-" }}
|
||||
</td>
|
||||
|
||||
<td bitCell class="tw-text-right">
|
||||
<button
|
||||
*ngIf="!item.readonly"
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="muted"
|
||||
appA11yTitle="{{ 'remove' | i18n }} {{ item.labelName }}"
|
||||
[disabled]="disabled"
|
||||
(click)="selectionList.deselectItem(item.id); handleBlur()"
|
||||
></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="selectionList.selectedItems.length == 0">
|
||||
<td bitCell>{{ emptySelectionText }}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
@@ -0,0 +1,250 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
|
||||
import {
|
||||
AvatarModule,
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
TableModule,
|
||||
TabsModule,
|
||||
} from "@bitwarden/components";
|
||||
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../../../tests/preloaded-english-i18n.module";
|
||||
|
||||
import { AccessSelectorComponent, PermissionMode } from "./access-selector.component";
|
||||
import { AccessItemType, CollectionPermission } from "./access-selector.models";
|
||||
import { UserTypePipe } from "./user-type.pipe";
|
||||
|
||||
/**
|
||||
* Helper class that makes it easier to test the AccessSelectorComponent by
|
||||
* exposing some protected methods/properties
|
||||
*/
|
||||
class TestableAccessSelectorComponent extends AccessSelectorComponent {
|
||||
selectItems(items: SelectItemView[]) {
|
||||
super.selectItems(items);
|
||||
}
|
||||
deselectItem(id: string) {
|
||||
this.selectionList.deselectItem(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper used to simulate a user selecting a new permission for a table row
|
||||
* @param index - "Row" index
|
||||
* @param perm - The new permission value
|
||||
*/
|
||||
changeSelectedItemPerm(index: number, perm: CollectionPermission) {
|
||||
this.selectionList.formArray.at(index).patchValue({
|
||||
permission: perm,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("AccessSelectorComponent", () => {
|
||||
let component: TestableAccessSelectorComponent;
|
||||
let fixture: ComponentFixture<TestableAccessSelectorComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
AvatarModule,
|
||||
BadgeModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
TabsModule,
|
||||
TableModule,
|
||||
PreloadedEnglishI18nModule,
|
||||
JslibModule,
|
||||
IconButtonModule,
|
||||
],
|
||||
declarations: [TestableAccessSelectorComponent, UserTypePipe],
|
||||
providers: [],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(TestableAccessSelectorComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
component.emptySelectionText = "Nothing selected";
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("item selection", () => {
|
||||
beforeEach(() => {
|
||||
component.items = [
|
||||
{
|
||||
id: "123",
|
||||
type: AccessItemType.Group,
|
||||
labelName: "Group 1",
|
||||
listName: "Group 1",
|
||||
},
|
||||
];
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should show the empty row when nothing is selected", () => {
|
||||
const emptyTableCell = fixture.nativeElement.querySelector("tbody tr td");
|
||||
expect(emptyTableCell?.textContent).toEqual("Nothing selected");
|
||||
});
|
||||
|
||||
it("should show one row when one value is selected", () => {
|
||||
component.selectItems([{ id: "123" } as any]);
|
||||
fixture.detectChanges();
|
||||
const firstColSpan = fixture.nativeElement.querySelector("tbody tr td span");
|
||||
expect(firstColSpan.textContent).toEqual("Group 1");
|
||||
});
|
||||
|
||||
it("should emit value change when a value is selected", () => {
|
||||
// Arrange
|
||||
const mockChange = jest.fn();
|
||||
component.registerOnChange(mockChange);
|
||||
component.permissionMode = PermissionMode.Edit;
|
||||
|
||||
// Act
|
||||
component.selectItems([{ id: "123" } as any]);
|
||||
|
||||
// Assert
|
||||
expect(mockChange.mock.calls.length).toEqual(1);
|
||||
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
|
||||
});
|
||||
|
||||
it("should emit value change when a row is modified", () => {
|
||||
// Arrange
|
||||
const mockChange = jest.fn();
|
||||
component.permissionMode = PermissionMode.Edit;
|
||||
component.selectItems([{ id: "123" } as any]);
|
||||
component.registerOnChange(mockChange); // Register change listener after setup
|
||||
|
||||
// Act
|
||||
component.changeSelectedItemPerm(0, CollectionPermission.Edit);
|
||||
|
||||
// Assert
|
||||
expect(mockChange.mock.calls.length).toEqual(1);
|
||||
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
|
||||
expect(mockChange.mock.lastCall[0]).toHaveProperty(
|
||||
"[0].permission",
|
||||
CollectionPermission.Edit
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit value change when a row is removed", () => {
|
||||
// Arrange
|
||||
const mockChange = jest.fn();
|
||||
component.permissionMode = PermissionMode.Edit;
|
||||
component.selectItems([{ id: "123" } as any]);
|
||||
component.registerOnChange(mockChange); // Register change listener after setup
|
||||
|
||||
// Act
|
||||
component.deselectItem("123");
|
||||
|
||||
// Assert
|
||||
expect(mockChange.mock.calls.length).toEqual(1);
|
||||
expect(mockChange.mock.lastCall[0].length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should emit permission values when in edit mode", () => {
|
||||
// Arrange
|
||||
const mockChange = jest.fn();
|
||||
component.registerOnChange(mockChange);
|
||||
component.permissionMode = PermissionMode.Edit;
|
||||
|
||||
// Act
|
||||
component.selectItems([{ id: "123" } as any]);
|
||||
|
||||
// Assert
|
||||
expect(mockChange.mock.calls.length).toEqual(1);
|
||||
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
|
||||
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].permission");
|
||||
});
|
||||
|
||||
it("should not emit permission values when not in edit mode", () => {
|
||||
// Arrange
|
||||
const mockChange = jest.fn();
|
||||
component.registerOnChange(mockChange);
|
||||
component.permissionMode = PermissionMode.Hidden;
|
||||
|
||||
// Act
|
||||
component.selectItems([{ id: "123" } as any]);
|
||||
|
||||
// Assert
|
||||
expect(mockChange.mock.calls.length).toEqual(1);
|
||||
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
|
||||
expect(mockChange.mock.lastCall[0]).not.toHaveProperty("[0].permission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("column rendering", () => {
|
||||
beforeEach(() => {
|
||||
component.items = [
|
||||
{
|
||||
id: "g1",
|
||||
type: AccessItemType.Group,
|
||||
labelName: "Group 1",
|
||||
listName: "Group 1",
|
||||
},
|
||||
{
|
||||
id: "m1",
|
||||
type: AccessItemType.Member,
|
||||
labelName: "Member 1",
|
||||
listName: "Member 1 (member1@email.com)",
|
||||
email: "member1@email.com",
|
||||
role: OrganizationUserType.Manager,
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
},
|
||||
];
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
test.each([true, false])("should show the role column when enabled", (columnEnabled) => {
|
||||
// Act
|
||||
component.showMemberRoles = columnEnabled;
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const colHeading = fixture.nativeElement.querySelector("#roleColHeading");
|
||||
expect(!!colHeading).toEqual(columnEnabled);
|
||||
});
|
||||
|
||||
test.each([true, false])("should show the group column when enabled", (columnEnabled) => {
|
||||
// Act
|
||||
component.showGroupColumn = columnEnabled;
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const colHeading = fixture.nativeElement.querySelector("#groupColHeading");
|
||||
expect(!!colHeading).toEqual(columnEnabled);
|
||||
});
|
||||
|
||||
const permissionColumnCases = [
|
||||
[PermissionMode.Hidden, false],
|
||||
[PermissionMode.Edit, true],
|
||||
[PermissionMode.Readonly, true],
|
||||
];
|
||||
|
||||
test.each(permissionColumnCases)(
|
||||
"should show the permission column when enabled",
|
||||
(mode: PermissionMode, shouldShowColumn) => {
|
||||
// Act
|
||||
component.permissionMode = mode;
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const colHeading = fixture.nativeElement.querySelector("#permissionColHeading");
|
||||
expect(!!colHeading).toEqual(shouldShowColumn);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Component, forwardRef, Input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { FormSelectionList } from "@bitwarden/angular/utils/form-selection-list";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
|
||||
|
||||
import {
|
||||
AccessItemType,
|
||||
AccessItemValue,
|
||||
AccessItemView,
|
||||
CollectionPermission,
|
||||
} from "./access-selector.models";
|
||||
|
||||
export enum PermissionMode {
|
||||
/**
|
||||
* No permission controls or column present. No permission values are emitted.
|
||||
*/
|
||||
Hidden = "hidden",
|
||||
|
||||
/**
|
||||
* No permission controls. Column rendered an if available on an item. No permission values are emitted
|
||||
*/
|
||||
Readonly = "readonly",
|
||||
|
||||
/**
|
||||
* Permission Controls and column present. Permission values are emitted.
|
||||
*/
|
||||
Edit = "edit",
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "bit-access-selector",
|
||||
templateUrl: "access-selector.component.html",
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => AccessSelectorComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AccessSelectorComponent implements ControlValueAccessor, OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private notifyOnChange: (v: unknown) => void;
|
||||
private notifyOnTouch: () => void;
|
||||
private pauseChangeNotification: boolean;
|
||||
|
||||
/**
|
||||
* The internal selection list that tracks the value of this form control / component.
|
||||
* It's responsible for keeping items sorted and synced with the rendered form controls
|
||||
* @protected
|
||||
*/
|
||||
protected selectionList = new FormSelectionList<AccessItemView, AccessItemValue>((item) => {
|
||||
const permissionControl = this.formBuilder.control(this.initialPermission);
|
||||
|
||||
const fg = this.formBuilder.group({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
permission: permissionControl,
|
||||
});
|
||||
|
||||
// Disable entire row form group if readonly
|
||||
if (item.readonly) {
|
||||
fg.disable();
|
||||
}
|
||||
|
||||
// Disable permission control if accessAllItems is enabled
|
||||
if (item.accessAllItems || this.permissionMode != PermissionMode.Edit) {
|
||||
permissionControl.disable();
|
||||
}
|
||||
|
||||
return fg;
|
||||
}, this._itemComparator.bind(this));
|
||||
|
||||
/**
|
||||
* Internal form group for this component.
|
||||
* @protected
|
||||
*/
|
||||
protected formGroup = this.formBuilder.group({
|
||||
items: this.selectionList.formArray,
|
||||
});
|
||||
|
||||
protected itemType = AccessItemType;
|
||||
protected permissionList = [
|
||||
{ perm: CollectionPermission.View, labelId: "canView" },
|
||||
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
|
||||
{ perm: CollectionPermission.Edit, labelId: "canEdit" },
|
||||
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
|
||||
];
|
||||
protected initialPermission = CollectionPermission.View;
|
||||
|
||||
disabled: boolean;
|
||||
|
||||
/**
|
||||
* List of all selectable items that. Sorted internally.
|
||||
*/
|
||||
@Input()
|
||||
get items(): AccessItemView[] {
|
||||
return this.selectionList.allItems;
|
||||
}
|
||||
|
||||
set items(val: AccessItemView[]) {
|
||||
const selected = (this.selectionList.formArray.getRawValue() ?? []).concat(
|
||||
val.filter((m) => m.readonly)
|
||||
);
|
||||
this.selectionList.populateItems(
|
||||
val.map((m) => {
|
||||
m.icon = m.icon ?? this.itemIcon(m); // Ensure an icon is set
|
||||
return m;
|
||||
}),
|
||||
selected
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission mode that controls if the permission form controls and column should be present.
|
||||
*/
|
||||
@Input()
|
||||
get permissionMode(): PermissionMode {
|
||||
return this._permissionMode;
|
||||
}
|
||||
|
||||
set permissionMode(value: PermissionMode) {
|
||||
this._permissionMode = value;
|
||||
// Toggle any internal permission controls
|
||||
for (const control of this.selectionList.formArray.controls) {
|
||||
if (value == PermissionMode.Edit) {
|
||||
control.get("permission").enable();
|
||||
} else {
|
||||
control.get("permission").disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
private _permissionMode: PermissionMode = PermissionMode.Hidden;
|
||||
|
||||
/**
|
||||
* Column header for the selected items table
|
||||
*/
|
||||
@Input() columnHeader: string;
|
||||
|
||||
/**
|
||||
* Label used for the ng selector
|
||||
*/
|
||||
@Input() selectorLabelText: string;
|
||||
|
||||
/**
|
||||
* Helper text displayed under the ng selector
|
||||
*/
|
||||
@Input() selectorHelpText: string;
|
||||
|
||||
/**
|
||||
* Text that is shown in the table when no items are selected
|
||||
*/
|
||||
@Input() emptySelectionText: string;
|
||||
|
||||
/**
|
||||
* Flag for if the member roles column should be present
|
||||
*/
|
||||
@Input() showMemberRoles: boolean;
|
||||
|
||||
/**
|
||||
* Flag for if the group column should be present
|
||||
*/
|
||||
@Input() showGroupColumn: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly formBuilder: FormBuilder,
|
||||
private readonly i18nService: I18nService
|
||||
) {}
|
||||
|
||||
/** Required for NG_VALUE_ACCESSOR */
|
||||
registerOnChange(fn: any): void {
|
||||
this.notifyOnChange = fn;
|
||||
}
|
||||
|
||||
/** Required for NG_VALUE_ACCESSOR */
|
||||
registerOnTouched(fn: any): void {
|
||||
this.notifyOnTouch = fn;
|
||||
}
|
||||
|
||||
/** Required for NG_VALUE_ACCESSOR */
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
|
||||
// Keep the internal FormGroup in sync
|
||||
if (this.disabled) {
|
||||
this.formGroup.disable();
|
||||
} else {
|
||||
this.formGroup.enable();
|
||||
}
|
||||
}
|
||||
|
||||
/** Required for NG_VALUE_ACCESSOR */
|
||||
writeValue(selectedItems: AccessItemValue[]): void {
|
||||
// Modifying the selection list, mistakenly fires valueChanges in the
|
||||
// internal form array, so we need to know to pause external notification
|
||||
this.pauseChangeNotification = true;
|
||||
|
||||
// Always clear the internal selection list on a new value
|
||||
this.selectionList.deselectAll();
|
||||
|
||||
// We need to also select any read only items to appear in the table
|
||||
this.selectionList.selectItems(this.items.filter((m) => m.readonly).map((m) => m.id));
|
||||
|
||||
// If the new value is null, then we're done
|
||||
if (selectedItems == null) {
|
||||
this.pauseChangeNotification = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Unable to handle other value types, throw
|
||||
if (!Array.isArray(selectedItems)) {
|
||||
throw new Error("The access selector component only supports Array form values!");
|
||||
}
|
||||
|
||||
// Iterate and internally select each item
|
||||
for (const value of selectedItems) {
|
||||
this.selectionList.selectItem(value.id, value);
|
||||
}
|
||||
|
||||
this.pauseChangeNotification = false;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Watch the internal formArray for changes and propagate them
|
||||
this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => {
|
||||
if (!this.notifyOnChange || this.pauseChangeNotification) {
|
||||
return;
|
||||
}
|
||||
// Disabled form arrays emit values for disabled controls, we override this to emit an empty array to avoid
|
||||
// emitting values for disabled controls that are "readonly" in the table
|
||||
if (this.selectionList.formArray.disabled) {
|
||||
this.notifyOnChange([]);
|
||||
return;
|
||||
}
|
||||
this.notifyOnChange(v);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
protected handleBlur() {
|
||||
if (!this.notifyOnTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyOnTouch();
|
||||
}
|
||||
|
||||
protected selectItems(items: SelectItemView[]) {
|
||||
this.pauseChangeNotification = true;
|
||||
this.selectionList.selectItems(items.map((i) => i.id));
|
||||
this.pauseChangeNotification = false;
|
||||
if (this.notifyOnChange != undefined) {
|
||||
this.notifyOnChange(this.selectionList.formArray.value);
|
||||
}
|
||||
}
|
||||
|
||||
protected itemIcon(item: AccessItemView) {
|
||||
switch (item.type) {
|
||||
case AccessItemType.Collection:
|
||||
return "bwi-collection";
|
||||
case AccessItemType.Group:
|
||||
return "bwi-users";
|
||||
case AccessItemType.Member:
|
||||
return "bwi-user";
|
||||
}
|
||||
}
|
||||
|
||||
protected permissionLabelId(perm: CollectionPermission) {
|
||||
return this.permissionList.find((p) => p.perm == perm)?.labelId;
|
||||
}
|
||||
|
||||
protected accessAllLabelId(item: AccessItemView) {
|
||||
return item.type == AccessItemType.Group ? "groupAccessAll" : "memberAccessAll";
|
||||
}
|
||||
|
||||
protected canEditItemPermission(item: AccessItemView) {
|
||||
return this.permissionMode == PermissionMode.Edit && !item.readonly && !item.accessAllItems;
|
||||
}
|
||||
|
||||
private _itemComparator(a: AccessItemView, b: AccessItemView) {
|
||||
return (
|
||||
a.type - b.type ||
|
||||
this.i18nService.collator.compare(a.listName, b.listName) ||
|
||||
this.i18nService.collator.compare(a.labelName, b.labelName) ||
|
||||
Number(b.readonly) - Number(a.readonly)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
|
||||
import { SelectItemView } from "@bitwarden/components";
|
||||
|
||||
import { CollectionAccessSelectionView } from "../../../../../organizations/core";
|
||||
|
||||
/**
|
||||
* Permission options that replace/correspond with readOnly and hidePassword server fields.
|
||||
*/
|
||||
export enum CollectionPermission {
|
||||
View = "view",
|
||||
ViewExceptPass = "viewExceptPass",
|
||||
Edit = "edit",
|
||||
EditExceptPass = "editExceptPass",
|
||||
}
|
||||
|
||||
export enum AccessItemType {
|
||||
Collection,
|
||||
Group,
|
||||
Member,
|
||||
}
|
||||
|
||||
/**
|
||||
* A "generic" type that describes an item that can be selected from a
|
||||
* ng-select list and have its collection permission modified.
|
||||
*
|
||||
* Currently, it supports Collections, Groups, and Members. Members require some additional
|
||||
* details to render in the AccessSelectorComponent so their type is defined separately
|
||||
* and then joined back with the base type.
|
||||
*
|
||||
*/
|
||||
export type AccessItemView =
|
||||
| SelectItemView & {
|
||||
/**
|
||||
* Flag that this group/member can access all items.
|
||||
* This will disable the permission editor for this item.
|
||||
*/
|
||||
accessAllItems?: boolean;
|
||||
|
||||
/**
|
||||
* Flag that this item cannot be modified.
|
||||
* This will disable the permission editor and will keep
|
||||
* the item always selected.
|
||||
*/
|
||||
readonly?: boolean;
|
||||
|
||||
/**
|
||||
* Optional permission that will be rendered for this
|
||||
* item if it set to readonly.
|
||||
*/
|
||||
readonlyPermission?: CollectionPermission;
|
||||
} & (
|
||||
| {
|
||||
type: AccessItemType.Collection;
|
||||
viaGroupName?: string;
|
||||
}
|
||||
| {
|
||||
type: AccessItemType.Group;
|
||||
}
|
||||
| {
|
||||
type: AccessItemType.Member; // Members have a few extra details required to display, so they're added here
|
||||
email: string;
|
||||
role: OrganizationUserType;
|
||||
status: OrganizationUserStatusType;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A type that is emitted as a value for the ngControl
|
||||
*/
|
||||
export type AccessItemValue = {
|
||||
id: string;
|
||||
permission?: CollectionPermission;
|
||||
type: AccessItemType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the CollectionAccessSelectionView interface to one of the new CollectionPermission values
|
||||
* for the dropdown in the AccessSelectorComponent
|
||||
* @param value
|
||||
*/
|
||||
export const convertToPermission = (value: CollectionAccessSelectionView) => {
|
||||
if (value.readOnly) {
|
||||
return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View;
|
||||
} else {
|
||||
return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an AccessItemValue back into a CollectionAccessView class using the CollectionPermission
|
||||
* to determine the values for `readOnly` and `hidePassword`
|
||||
* @param value
|
||||
*/
|
||||
export const convertToSelectionView = (value: AccessItemValue) => {
|
||||
return new CollectionAccessSelectionView({
|
||||
id: value.id,
|
||||
readOnly: readOnly(value.permission),
|
||||
hidePasswords: hidePassword(value.permission),
|
||||
});
|
||||
};
|
||||
|
||||
const readOnly = (perm: CollectionPermission) =>
|
||||
[CollectionPermission.View, CollectionPermission.ViewExceptPass].includes(perm);
|
||||
|
||||
const hidePassword = (perm: CollectionPermission) =>
|
||||
[CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { SharedModule } from "../../../../../shared";
|
||||
|
||||
import { AccessSelectorComponent } from "./access-selector.component";
|
||||
import { UserTypePipe } from "./user-type.pipe";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule],
|
||||
declarations: [AccessSelectorComponent, UserTypePipe],
|
||||
exports: [AccessSelectorComponent],
|
||||
})
|
||||
export class AccessSelectorModule {}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
|
||||
import {
|
||||
AvatarModule,
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
TableModule,
|
||||
TabsModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../../../tests/preloaded-english-i18n.module";
|
||||
|
||||
import { AccessSelectorComponent } from "./access-selector.component";
|
||||
import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models";
|
||||
import { UserTypePipe } from "./user-type.pipe";
|
||||
|
||||
export default {
|
||||
title: "Web/Organizations/Access Selector",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [AccessSelectorComponent, UserTypePipe],
|
||||
imports: [
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
AvatarModule,
|
||||
BadgeModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
TabsModule,
|
||||
TableModule,
|
||||
PreloadedEnglishI18nModule,
|
||||
JslibModule,
|
||||
IconButtonModule,
|
||||
],
|
||||
providers: [],
|
||||
}),
|
||||
],
|
||||
parameters: {},
|
||||
argTypes: {
|
||||
formObj: { table: { disable: true } },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const actionsData = {
|
||||
onValueChanged: action("onValueChanged"),
|
||||
onSubmit: action("onSubmit"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory to help build semi-realistic looking items
|
||||
* @param n - The number of items to build
|
||||
* @param type - Which type to build
|
||||
*/
|
||||
const itemsFactory = (n: number, type: AccessItemType) => {
|
||||
return [...Array(n)].map((_: unknown, id: number) => {
|
||||
const item: AccessItemView = {
|
||||
id: id.toString(),
|
||||
type: type,
|
||||
} as AccessItemView;
|
||||
|
||||
switch (item.type) {
|
||||
case AccessItemType.Collection:
|
||||
item.labelName = item.listName = `Collection ${id}`;
|
||||
item.id = item.id + "c";
|
||||
item.parentGrouping = "Collection Parent Group " + ((id % 2) + 1);
|
||||
break;
|
||||
case AccessItemType.Group:
|
||||
item.labelName = item.listName = `Group ${id}`;
|
||||
item.id = item.id + "g";
|
||||
break;
|
||||
case AccessItemType.Member:
|
||||
item.id = item.id + "m";
|
||||
item.email = `member${id}@email.com`;
|
||||
item.status = id % 3 == 0 ? 0 : 2;
|
||||
item.labelName = item.status == 2 ? `Member ${id}` : item.email;
|
||||
item.listName = item.status == 2 ? `${item.labelName} (${item.email})` : item.email;
|
||||
item.role = id % 5;
|
||||
break;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
const sampleMembers = itemsFactory(10, AccessItemType.Member);
|
||||
const sampleGroups = itemsFactory(6, AccessItemType.Group);
|
||||
|
||||
const StandaloneAccessSelectorTemplate: Story<AccessSelectorComponent> = (
|
||||
args: AccessSelectorComponent
|
||||
) => ({
|
||||
props: {
|
||||
items: [],
|
||||
valueChanged: actionsData.onValueChanged,
|
||||
initialValue: [],
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
<bit-access-selector
|
||||
(ngModelChange)="valueChanged($event)"
|
||||
[ngModel]="initialValue"
|
||||
[items]="items"
|
||||
[disabled]="disabled"
|
||||
[columnHeader]="columnHeader"
|
||||
[showGroupColumn]="showGroupColumn"
|
||||
[selectorLabelText]="selectorLabelText"
|
||||
[selectorHelpText]="selectorHelpText"
|
||||
[emptySelectionText]="emptySelectionText"
|
||||
[permissionMode]="permissionMode"
|
||||
[showMemberRoles]="showMemberRoles"
|
||||
></bit-access-selector>
|
||||
`,
|
||||
});
|
||||
|
||||
const DialogAccessSelectorTemplate: Story<AccessSelectorComponent> = (
|
||||
args: AccessSelectorComponent
|
||||
) => ({
|
||||
props: {
|
||||
items: [],
|
||||
valueChanged: actionsData.onValueChanged,
|
||||
initialValue: [],
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
|
||||
<span bitDialogTitle>Access selector</span>
|
||||
<span bitDialogContent>
|
||||
<bit-access-selector
|
||||
(ngModelChange)="valueChanged($event)"
|
||||
[ngModel]="initialValue"
|
||||
[items]="items"
|
||||
[disabled]="disabled"
|
||||
[columnHeader]="columnHeader"
|
||||
[showGroupColumn]="showGroupColumn"
|
||||
[selectorLabelText]="selectorLabelText"
|
||||
[selectorHelpText]="selectorHelpText"
|
||||
[emptySelectionText]="emptySelectionText"
|
||||
[permissionMode]="permissionMode"
|
||||
[showMemberRoles]="showMemberRoles"
|
||||
></bit-access-selector>
|
||||
</span>
|
||||
<div bitDialogFooter class="tw-flex tw-items-center tw-flex-row tw-gap-2">
|
||||
<button bitButton buttonType="primary">Save</button>
|
||||
<button bitButton buttonType="secondary">Cancel</button>
|
||||
<button
|
||||
class="tw-ml-auto"
|
||||
bitIconButton="bwi-trash"
|
||||
buttonType="danger"
|
||||
size="default"
|
||||
title="Delete"
|
||||
aria-label="Delete"></button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
`,
|
||||
});
|
||||
|
||||
const dialogAccessItems = itemsFactory(10, AccessItemType.Collection);
|
||||
|
||||
const memberCollectionAccessItems = itemsFactory(3, AccessItemType.Collection).concat([
|
||||
{
|
||||
id: "c1-group1",
|
||||
type: AccessItemType.Collection,
|
||||
labelName: "Collection 1",
|
||||
listName: "Collection 1",
|
||||
viaGroupName: "Group 1",
|
||||
readonlyPermission: CollectionPermission.View,
|
||||
readonly: true,
|
||||
},
|
||||
{
|
||||
id: "c1-group2",
|
||||
type: AccessItemType.Collection,
|
||||
labelName: "Collection 1",
|
||||
listName: "Collection 1",
|
||||
viaGroupName: "Group 2",
|
||||
readonlyPermission: CollectionPermission.ViewExceptPass,
|
||||
readonly: true,
|
||||
},
|
||||
]);
|
||||
|
||||
export const Dialog = DialogAccessSelectorTemplate.bind({});
|
||||
Dialog.args = {
|
||||
permissionMode: "edit",
|
||||
showMemberRoles: false,
|
||||
showGroupColumn: true,
|
||||
columnHeader: "Collection",
|
||||
selectorLabelText: "Select Collections",
|
||||
selectorHelpText: "Some helper text describing what this does",
|
||||
emptySelectionText: "No collections added",
|
||||
disabled: false,
|
||||
initialValue: [],
|
||||
items: dialogAccessItems,
|
||||
};
|
||||
Dialog.story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
storyDescription: `
|
||||
Example of an access selector for modifying the collections a member has access to inside of a dialog.
|
||||
`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MemberCollectionAccess = StandaloneAccessSelectorTemplate.bind({});
|
||||
MemberCollectionAccess.args = {
|
||||
permissionMode: "edit",
|
||||
showMemberRoles: false,
|
||||
showGroupColumn: true,
|
||||
columnHeader: "Collection",
|
||||
selectorLabelText: "Select Collections",
|
||||
selectorHelpText: "Some helper text describing what this does",
|
||||
emptySelectionText: "No collections added",
|
||||
disabled: false,
|
||||
initialValue: [],
|
||||
items: memberCollectionAccessItems,
|
||||
};
|
||||
MemberCollectionAccess.story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
storyDescription: `
|
||||
Example of an access selector for modifying the collections a member has access to.
|
||||
Includes examples of a readonly group and member that cannot be edited.
|
||||
`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MemberGroupAccess = StandaloneAccessSelectorTemplate.bind({});
|
||||
MemberGroupAccess.args = {
|
||||
permissionMode: "readonly",
|
||||
showMemberRoles: false,
|
||||
columnHeader: "Groups",
|
||||
selectorLabelText: "Select Groups",
|
||||
selectorHelpText: "Some helper text describing what this does",
|
||||
emptySelectionText: "No groups added",
|
||||
disabled: false,
|
||||
initialValue: [{ id: "3g" }, { id: "0g" }],
|
||||
items: itemsFactory(4, AccessItemType.Group).concat([
|
||||
{
|
||||
id: "admin",
|
||||
type: AccessItemType.Group,
|
||||
listName: "Admin Group",
|
||||
labelName: "Admin Group",
|
||||
accessAllItems: true,
|
||||
},
|
||||
]),
|
||||
};
|
||||
MemberGroupAccess.story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
storyDescription: `
|
||||
Example of an access selector for selecting which groups an individual member belongs too.
|
||||
`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GroupMembersAccess = StandaloneAccessSelectorTemplate.bind({});
|
||||
GroupMembersAccess.args = {
|
||||
permissionMode: "hidden",
|
||||
showMemberRoles: true,
|
||||
columnHeader: "Members",
|
||||
selectorLabelText: "Select Members",
|
||||
selectorHelpText: "Some helper text describing what this does",
|
||||
emptySelectionText: "No members added",
|
||||
disabled: false,
|
||||
initialValue: [{ id: "2m" }, { id: "0m" }],
|
||||
items: sampleMembers,
|
||||
};
|
||||
GroupMembersAccess.story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
storyDescription: `
|
||||
Example of an access selector for selecting which members belong to an specific group.
|
||||
`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CollectionAccess = StandaloneAccessSelectorTemplate.bind({});
|
||||
CollectionAccess.args = {
|
||||
permissionMode: "edit",
|
||||
showMemberRoles: false,
|
||||
columnHeader: "Groups/Members",
|
||||
selectorLabelText: "Select groups and members",
|
||||
selectorHelpText:
|
||||
"Permissions set for a member will replace permissions set by that member's group",
|
||||
emptySelectionText: "No members or groups added",
|
||||
disabled: false,
|
||||
initialValue: [
|
||||
{ id: "3g", permission: CollectionPermission.EditExceptPass },
|
||||
{ id: "0m", permission: CollectionPermission.View },
|
||||
],
|
||||
items: sampleGroups.concat(sampleMembers).concat([
|
||||
{
|
||||
id: "admin-group",
|
||||
type: AccessItemType.Group,
|
||||
listName: "Admin Group",
|
||||
labelName: "Admin Group",
|
||||
accessAllItems: true,
|
||||
readonly: true,
|
||||
},
|
||||
{
|
||||
id: "admin-member",
|
||||
type: AccessItemType.Member,
|
||||
listName: "Admin Member (admin@email.com)",
|
||||
labelName: "Admin Member",
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
role: OrganizationUserType.Admin,
|
||||
email: "admin@email.com",
|
||||
accessAllItems: true,
|
||||
readonly: true,
|
||||
},
|
||||
]),
|
||||
};
|
||||
GroupMembersAccess.story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
storyDescription: `
|
||||
Example of an access selector for selecting which members/groups have access to a specific collection.
|
||||
`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fb = new FormBuilder();
|
||||
|
||||
const ReactiveFormAccessSelectorTemplate: Story<AccessSelectorComponent> = (
|
||||
args: AccessSelectorComponent
|
||||
) => ({
|
||||
props: {
|
||||
items: [],
|
||||
onSubmit: actionsData.onSubmit,
|
||||
...args,
|
||||
},
|
||||
template: `
|
||||
<form [formGroup]="formObj" (ngSubmit)="onSubmit(formObj.controls.formItems.value)">
|
||||
<bit-access-selector
|
||||
formControlName="formItems"
|
||||
[items]="items"
|
||||
[columnHeader]="columnHeader"
|
||||
[selectorLabelText]="selectorLabelText"
|
||||
[selectorHelpText]="selectorHelpText"
|
||||
[emptySelectionText]="emptySelectionText"
|
||||
[permissionMode]="permissionMode"
|
||||
[showMemberRoles]="showMemberRoles"
|
||||
></bit-access-selector>
|
||||
<button type="submit" bitButton buttonType="primary" class="tw-mt-5">Submit</button>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
export const ReactiveForm = ReactiveFormAccessSelectorTemplate.bind({});
|
||||
ReactiveForm.args = {
|
||||
formObj: fb.group({ formItems: [[{ id: "1g" }]] }),
|
||||
permissionMode: "edit",
|
||||
showMemberRoles: false,
|
||||
columnHeader: "Groups/Members",
|
||||
selectorLabelText: "Select groups and members",
|
||||
selectorHelpText:
|
||||
"Permissions set for a member will replace permissions set by that member's group",
|
||||
emptySelectionText: "No members or groups added",
|
||||
items: sampleGroups.concat(sampleMembers),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./access-selector.component";
|
||||
export * from "./access-selector.module";
|
||||
export * from "./access-selector.models";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
|
||||
|
||||
@Pipe({
|
||||
name: "userType",
|
||||
})
|
||||
export class UserTypePipe implements PipeTransform {
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
transform(value?: OrganizationUserType, unknownText?: string): string {
|
||||
if (value == null) {
|
||||
return unknownText ?? this.i18nService.t("unknown");
|
||||
}
|
||||
switch (value) {
|
||||
case OrganizationUserType.Owner:
|
||||
return this.i18nService.t("owner");
|
||||
case OrganizationUserType.Admin:
|
||||
return this.i18nService.t("admin");
|
||||
case OrganizationUserType.User:
|
||||
return this.i18nService.t("user");
|
||||
case OrganizationUserType.Manager:
|
||||
return this.i18nService.t("manager");
|
||||
case OrganizationUserType.Custom:
|
||||
return this.i18nService.t("custom");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [disablePadding]="!loading">
|
||||
<span bitDialogTitle>
|
||||
<ng-container *ngIf="editMode">
|
||||
{{ "editCollection" | i18n }}
|
||||
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading">{{
|
||||
collection.name
|
||||
}}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!editMode">
|
||||
{{ "newCollection" | i18n }}
|
||||
</ng-container>
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<ng-container *ngIf="loading" #spinner>
|
||||
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
|
||||
</ng-container>
|
||||
<bit-tab-group *ngIf="!loading" [(selectedIndex)]="tabIndex">
|
||||
<bit-tab label="{{ 'collectionInfo' | i18n }}">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "name" | i18n }}</bit-label>
|
||||
<input bitInput appAutofocus formControlName="name" />
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "externalId" | i18n }}</bit-label>
|
||||
<input bitInput formControlName="externalId" />
|
||||
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "nestCollectionUnder" | i18n }}</bit-label>
|
||||
<bit-select bitInput formControlName="parent">
|
||||
<bit-option [value]="undefined" [label]="'noCollection' | i18n"> </bit-option>
|
||||
<bit-option
|
||||
*ngIf="deletedParentName"
|
||||
disabled
|
||||
icon="bwi-collection"
|
||||
[value]="deletedParentName"
|
||||
label="{{ deletedParentName }} ({{ 'deleted' | i18n }})"
|
||||
>
|
||||
</bit-option>
|
||||
<bit-option
|
||||
*ngFor="let collection of nestOptions"
|
||||
icon="bwi-collection"
|
||||
[value]="collection.name"
|
||||
[label]="collection.name"
|
||||
>
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</bit-tab>
|
||||
<bit-tab label="{{ 'access' | i18n }}">
|
||||
<bit-access-selector
|
||||
*ngIf="organization.useGroups"
|
||||
[permissionMode]="PermissionMode.Edit"
|
||||
formControlName="access"
|
||||
[items]="accessItems"
|
||||
[columnHeader]="'groupAndMemberColumnHeader' | i18n"
|
||||
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
|
||||
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
|
||||
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
|
||||
></bit-access-selector>
|
||||
<bit-access-selector
|
||||
*ngIf="!organization.useGroups"
|
||||
[permissionMode]="PermissionMode.Edit"
|
||||
formControlName="access"
|
||||
[items]="accessItems"
|
||||
[columnHeader]="'memberColumnHeader' | i18n"
|
||||
[selectorLabelText]="'selectMembers' | i18n"
|
||||
[emptySelectionText]="'noMembersAdded' | i18n"
|
||||
></bit-access-selector>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
</div>
|
||||
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
(click)="cancel()"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="editMode && organization?.canDeleteAssignedCollections"
|
||||
type="button"
|
||||
bitIconButton="bwi-trash"
|
||||
buttonType="danger"
|
||||
class="tw-ml-auto"
|
||||
bitFormButton
|
||||
[appA11yTitle]="'delete' | i18n"
|
||||
[bitAction]="delete"
|
||||
[disabled]="loading"
|
||||
></button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -0,0 +1,302 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
|
||||
import { BitValidators, DialogService } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
CollectionAdminService,
|
||||
CollectionAdminView,
|
||||
GroupService,
|
||||
GroupView,
|
||||
} from "../../../../../organizations/core";
|
||||
import {
|
||||
AccessItemType,
|
||||
AccessItemValue,
|
||||
AccessItemView,
|
||||
convertToPermission,
|
||||
convertToSelectionView,
|
||||
PermissionMode,
|
||||
} from "../access-selector";
|
||||
|
||||
export enum CollectionDialogTabType {
|
||||
Info = 0,
|
||||
Access = 1,
|
||||
}
|
||||
|
||||
export interface CollectionDialogParams {
|
||||
collectionId?: string;
|
||||
organizationId: string;
|
||||
initialTab?: CollectionDialogTabType;
|
||||
parentCollectionId?: string;
|
||||
}
|
||||
|
||||
export enum CollectionDialogResult {
|
||||
Saved = "saved",
|
||||
Canceled = "canceled",
|
||||
Deleted = "deleted",
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-collection-dialog",
|
||||
templateUrl: "collection-dialog.component.html",
|
||||
})
|
||||
export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected tabIndex: CollectionDialogTabType;
|
||||
protected loading = true;
|
||||
protected organization?: Organization;
|
||||
protected collection?: CollectionView;
|
||||
protected nestOptions: CollectionView[] = [];
|
||||
protected accessItems: AccessItemView[] = [];
|
||||
protected deletedParentName: string | undefined;
|
||||
protected formGroup = this.formBuilder.group({
|
||||
name: ["", [Validators.required, BitValidators.forbiddenCharacters(["/"])]],
|
||||
externalId: "",
|
||||
parent: undefined as string | undefined,
|
||||
access: [[] as AccessItemValue[]],
|
||||
});
|
||||
protected PermissionMode = PermissionMode;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
|
||||
private formBuilder: FormBuilder,
|
||||
private dialogRef: DialogRef<CollectionDialogResult>,
|
||||
private organizationService: OrganizationService,
|
||||
private groupService: GroupService,
|
||||
private collectionService: CollectionAdminService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private organizationUserService: OrganizationUserService
|
||||
) {
|
||||
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe(
|
||||
shareReplay({ refCount: true, bufferSize: 1 })
|
||||
);
|
||||
const groups$ = organization$.pipe(
|
||||
switchMap((organization) => {
|
||||
if (!organization.useGroups) {
|
||||
return of([] as GroupView[]);
|
||||
}
|
||||
|
||||
return this.groupService.getAll(this.params.organizationId);
|
||||
})
|
||||
);
|
||||
|
||||
combineLatest({
|
||||
organization: organization$,
|
||||
collections: this.collectionService.getAll(this.params.organizationId),
|
||||
collectionDetails: this.params.collectionId
|
||||
? this.collectionService.get(this.params.organizationId, this.params.collectionId)
|
||||
: of(null),
|
||||
groups: groups$,
|
||||
users: this.organizationUserService.getAllUsers(this.params.organizationId),
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(({ organization, collections, collectionDetails, groups, users }) => {
|
||||
this.organization = organization;
|
||||
this.accessItems = [].concat(
|
||||
groups.map(mapGroupToAccessItemView),
|
||||
users.data.map(mapUserToAccessItemView)
|
||||
);
|
||||
|
||||
if (this.params.collectionId) {
|
||||
this.collection = collections.find((c) => c.id === this.collectionId);
|
||||
this.nestOptions = collections.filter((c) => c.id !== this.collectionId);
|
||||
|
||||
if (!this.collection) {
|
||||
throw new Error("Could not find collection to edit.");
|
||||
}
|
||||
|
||||
const { name, parent } = parseName(this.collection);
|
||||
if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) {
|
||||
this.deletedParentName = parent;
|
||||
}
|
||||
|
||||
const accessSelections = mapToAccessSelections(collectionDetails);
|
||||
this.formGroup.patchValue({
|
||||
name,
|
||||
externalId: this.collection.externalId,
|
||||
parent,
|
||||
access: accessSelections,
|
||||
});
|
||||
} else {
|
||||
this.nestOptions = collections;
|
||||
const parent = collections.find((c) => c.id === this.params.parentCollectionId);
|
||||
this.formGroup.patchValue({ parent: parent?.name ?? undefined });
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
protected get collectionId() {
|
||||
return this.params.collectionId;
|
||||
}
|
||||
|
||||
protected get editMode() {
|
||||
return this.params.collectionId != undefined;
|
||||
}
|
||||
|
||||
protected async cancel() {
|
||||
this.close(CollectionDialogResult.Canceled);
|
||||
}
|
||||
|
||||
protected submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
if (this.tabIndex === CollectionDialogTabType.Access) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("collectionInfo"))
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const collectionView = new CollectionAdminView();
|
||||
collectionView.id = this.params.collectionId;
|
||||
collectionView.organizationId = this.params.organizationId;
|
||||
collectionView.externalId = this.formGroup.controls.externalId.value;
|
||||
collectionView.groups = this.formGroup.controls.access.value
|
||||
.filter((v) => v.type === AccessItemType.Group)
|
||||
.map(convertToSelectionView);
|
||||
collectionView.users = this.formGroup.controls.access.value
|
||||
.filter((v) => v.type === AccessItemType.Member)
|
||||
.map(convertToSelectionView);
|
||||
|
||||
const parent = this.formGroup.controls.parent.value;
|
||||
if (parent) {
|
||||
collectionView.name = `${parent}/${this.formGroup.controls.name.value}`;
|
||||
} else {
|
||||
collectionView.name = this.formGroup.controls.name.value;
|
||||
}
|
||||
|
||||
await this.collectionService.save(collectionView);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t(
|
||||
this.editMode ? "editedCollectionId" : "createdCollectionId",
|
||||
collectionView.name
|
||||
)
|
||||
);
|
||||
|
||||
this.close(CollectionDialogResult.Saved);
|
||||
};
|
||||
|
||||
protected delete = async () => {
|
||||
const confirmed = await this.platformUtilsService.showDialog(
|
||||
this.i18nService.t("deleteCollectionConfirmation"),
|
||||
this.collection?.name,
|
||||
this.i18nService.t("yes"),
|
||||
this.i18nService.t("no"),
|
||||
"warning"
|
||||
);
|
||||
|
||||
if (!confirmed && this.params.collectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.collectionService.delete(this.params.organizationId, this.params.collectionId);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("deletedCollectionId", this.collection?.name)
|
||||
);
|
||||
|
||||
this.close(CollectionDialogResult.Deleted);
|
||||
};
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private close(result: CollectionDialogResult) {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
|
||||
function parseName(collection: CollectionView) {
|
||||
const nameParts = collection.name?.split("/");
|
||||
const name = nameParts[nameParts.length - 1];
|
||||
const parent = nameParts.length > 1 ? nameParts.slice(0, -1).join("/") : undefined;
|
||||
|
||||
return { name, parent };
|
||||
}
|
||||
|
||||
function mapGroupToAccessItemView(group: GroupView): AccessItemView {
|
||||
return {
|
||||
id: group.id,
|
||||
type: AccessItemType.Group,
|
||||
listName: group.name,
|
||||
labelName: group.name,
|
||||
accessAllItems: group.accessAll,
|
||||
readonly: group.accessAll,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Use view when user apis are migrated to a service
|
||||
function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView {
|
||||
return {
|
||||
id: user.id,
|
||||
type: AccessItemType.Member,
|
||||
email: user.email,
|
||||
role: user.type,
|
||||
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
|
||||
labelName: user.name ?? user.email,
|
||||
status: user.status,
|
||||
accessAllItems: user.accessAll,
|
||||
readonly: user.accessAll,
|
||||
};
|
||||
}
|
||||
|
||||
function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] {
|
||||
if (collectionDetails == undefined) {
|
||||
return [];
|
||||
}
|
||||
return [].concat(
|
||||
collectionDetails.groups.map<AccessItemValue>((selection) => ({
|
||||
id: selection.id,
|
||||
type: AccessItemType.Group,
|
||||
permission: convertToPermission(selection),
|
||||
})),
|
||||
collectionDetails.users.map<AccessItemValue>((selection) => ({
|
||||
id: selection.id,
|
||||
type: AccessItemType.Member,
|
||||
permission: convertToPermission(selection),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed helper to open a CollectionDialog
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param config Configuration for the dialog
|
||||
*/
|
||||
export function openCollectionDialog(
|
||||
dialogService: DialogService,
|
||||
config: DialogConfig<CollectionDialogParams>
|
||||
) {
|
||||
return dialogService.open<CollectionDialogResult, CollectionDialogParams>(
|
||||
CollectionDialogComponent,
|
||||
config
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { SelectModule } from "@bitwarden/components";
|
||||
|
||||
import { AccessSelectorModule } from "../../../../../admin-console/organizations/shared/components/access-selector/access-selector.module";
|
||||
import { SharedModule } from "../../../../../shared";
|
||||
|
||||
import { CollectionDialogComponent } from "./collection-dialog.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, AccessSelectorModule, SelectModule],
|
||||
declarations: [CollectionDialogComponent],
|
||||
exports: [CollectionDialogComponent],
|
||||
})
|
||||
export class CollectionDialogModule {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./collection-dialog.component";
|
||||
export * from "./collection-dialog.module";
|
||||
Reference in New Issue
Block a user