1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-30 15:13:32 +00:00

[PM-13326] Move Collections tab to AC Team (#13529)

This commit is contained in:
Thomas Rittson
2025-03-04 09:18:42 +11:00
committed by GitHub
parent 13213585b2
commit 56c8c2ccc8
22 changed files with 64 additions and 65 deletions

View File

@@ -0,0 +1,41 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading" dialogSize="large">
<span bitDialogTitle>
{{ "assignCollectionAccess" | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted">
{{ numCollections }} {{ (numCollections == 1 ? "collection" : "collections") | i18n }}
</span>
</span>
<div bitDialogContent>
<bit-access-selector
*ngIf="organization?.useGroups"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'groupSlashMemberColumnHeader' | i18n"
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelperDesc' | 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>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,149 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { combineLatest, of, Subject, switchMap, takeUntil } from "rxjs";
import {
CollectionAdminService,
OrganizationUserApiService,
CollectionView,
} from "@bitwarden/admin-console/common";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../../shared";
import { GroupApiService, GroupView } from "../../core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
AccessSelectorModule,
convertToSelectionView,
mapGroupToAccessItemView,
mapUserToAccessItemView,
PermissionMode,
} from "../../shared/components/access-selector";
export interface BulkCollectionsDialogParams {
organizationId: string;
collections: CollectionView[];
}
export enum BulkCollectionsDialogResult {
Saved = "saved",
Canceled = "canceled",
}
@Component({
imports: [SharedModule, AccessSelectorModule],
selector: "app-bulk-collections-dialog",
templateUrl: "bulk-collections-dialog.component.html",
standalone: true,
})
export class BulkCollectionsDialogComponent implements OnDestroy {
protected readonly PermissionMode = PermissionMode;
protected formGroup = this.formBuilder.group({
access: [[] as AccessItemValue[]],
});
protected loading = true;
protected organization: Organization;
protected accessItems: AccessItemView[] = [];
protected numCollections: number;
private destroy$ = new Subject<void>();
constructor(
@Inject(DIALOG_DATA) private params: BulkCollectionsDialogParams,
private dialogRef: DialogRef<BulkCollectionsDialogResult>,
private formBuilder: FormBuilder,
private organizationService: OrganizationService,
private accountService: AccountService,
private groupService: GroupApiService,
private organizationUserApiService: OrganizationUserApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private collectionAdminService: CollectionAdminService,
private toastService: ToastService,
) {
this.numCollections = this.params.collections.length;
const organization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.organizationService
.organizations$(account?.id)
.pipe(getOrganizationById(this.params.organizationId)),
),
);
const groups$ = organization$.pipe(
switchMap((organization) => {
if (!organization.useGroups) {
return of([] as GroupView[]);
}
return this.groupService.getAll(organization.id);
}),
);
combineLatest([
organization$,
groups$,
this.organizationUserApiService.getAllMiniUserDetails(this.params.organizationId),
])
.pipe(takeUntil(this.destroy$))
.subscribe(([organization, groups, users]) => {
this.organization = organization;
this.accessItems = [].concat(
groups.map(mapGroupToAccessItemView),
users.data.map(mapUserToAccessItemView),
);
this.loading = false;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
submit = async () => {
const users = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Member)
.map(convertToSelectionView);
const groups = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Group)
.map(convertToSelectionView);
await this.collectionAdminService.bulkAssignAccess(
this.organization.id,
this.params.collections.map((c) => c.id),
users,
groups,
);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("editedCollections"),
});
this.dialogRef.close(BulkCollectionsDialogResult.Saved);
};
static open(dialogService: DialogService, config: DialogConfig<BulkCollectionsDialogParams>) {
return dialogService.open<BulkCollectionsDialogResult, BulkCollectionsDialogParams>(
BulkCollectionsDialogComponent,
config,
);
}
}

View File

@@ -0,0 +1 @@
export * from "./bulk-collections-dialog.component";

View File

@@ -0,0 +1,52 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { CollectionDialogTabType } from "../../../vault/components/collection-dialog";
const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="10 -10 120 140" fill="none">
<rect class="tw-stroke-secondary-600" width="134" height="86" x="3" y="31.485" stroke-width="6" rx="11"/>
<path class="tw-fill-secondary-600" d="M123.987 20.15H14.779a3.114 3.114 0 0 1-2.083-.95 3.036 3.036 0 0 1 0-4.208 3.125 3.125 0 0 1 2.083-.951h109.208c.792.043 1.536.38 2.083.95a3.035 3.035 0 0 1 0 4.208 3.115 3.115 0 0 1-2.083.95Zm-6.649-14.041h-95.91a3.114 3.114 0 0 1-2.082-.95 3.036 3.036 0 0 1-.848-2.105c0-.782.306-1.538.848-2.104A3.125 3.125 0 0 1 21.43 0h95.909c.791.043 1.535.38 2.082.95.547.57.849 1.322.849 2.104a3.05 3.05 0 0 1-.849 2.104 3.115 3.115 0 0 1-2.082.95ZM95.132 74.407A42.317 42.317 0 0 0 83.59 65.43l8.799-8.657a1.59 1.59 0 0 0 .004-2.27 1.641 1.641 0 0 0-2.298-.004l-9.64 9.479a28.017 28.017 0 0 0-10.483-2.13c-14.323 0-24.814 12.342-25.298 12.89a2.431 2.431 0 0 0-.675 1.64c-.01.612.215 1.203.626 1.66a43.981 43.981 0 0 0 11.873 9.485l-8.806 8.658a1.601 1.601 0 0 0-.499 1.138 1.602 1.602 0 0 0 1.008 1.5 1.651 1.651 0 0 0 1.255-.009c.199-.085.379-.205.528-.359l9.634-9.443a27.16 27.16 0 0 0 10.359 2.158c14.323 0 24.753-12.086 25.23-12.63a2.983 2.983 0 0 0-.078-4.128h.002ZM49.204 77.82a1.82 1.82 0 0 1-.43-.6 1.767 1.767 0 0 1-.152-.72 1.778 1.778 0 0 1 .582-1.32c3.857-3.564 11.782-9.686 20.77-9.676 2.564.037 5.105.508 7.508 1.395l-3.291 3.235a7.793 7.793 0 0 0-5.02-1.226 7.746 7.746 0 0 0-4.676 2.18 7.528 7.528 0 0 0-1 9.563l-4.199 4.143a43.135 43.135 0 0 1-10.092-6.974Zm26.059-1.318a5.19 5.19 0 0 1-1.557 3.68 5.326 5.326 0 0 1-3.733 1.521c-.82-.005-1.63-.2-2.359-.57l7.067-6.952c.377.718.575 1.513.582 2.321Zm-10.58 0a5.136 5.136 0 0 1 .673-2.555 5.204 5.204 0 0 1 1.862-1.897 5.302 5.302 0 0 1 5.172-.146l-7.096 6.977a5.06 5.06 0 0 1-.61-2.379Zm26.053 1.331c-3.857 3.56-11.779 9.677-20.763 9.677a22.723 22.723 0 0 1-7.454-1.369l3.292-3.226a7.793 7.793 0 0 0 4.995 1.192 7.734 7.734 0 0 0 4.642-2.176 7.524 7.524 0 0 0 1.033-9.506l4.224-4.168a43.258 43.258 0 0 1 10.02 6.945 1.788 1.788 0 0 1 .585 1.313 1.788 1.788 0 0 1-.577 1.318h.003Z"/>
</svg>`;
@Component({
selector: "collection-access-restricted",
standalone: true,
imports: [SharedModule, ButtonModule, NoItemsModule],
template: `<bit-no-items [icon]="icon" class="tw-mt-2 tw-block">
<span slot="title" class="tw-mt-4 tw-block">{{ "youDoNotHavePermissions" | i18n }}</span>
<button
*ngIf="canEditCollection"
slot="button"
bitButton
(click)="viewCollectionClicked.emit({ readonly: false, tab: collectionDialogTabType.Info })"
buttonType="secondary"
type="button"
>
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "editCollection" | i18n }}
</button>
<button
*ngIf="!canEditCollection && canViewCollectionInfo"
slot="button"
bitButton
(click)="viewCollectionClicked.emit({ readonly: true, tab: collectionDialogTabType.Access })"
buttonType="secondary"
type="button"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "viewAccess" | i18n }}
</button>
</bit-no-items>`,
})
export class CollectionAccessRestrictedComponent {
protected icon = icon;
protected collectionDialogTabType = CollectionDialogTabType;
@Input() canEditCollection = false;
@Input() canViewCollectionInfo = false;
@Output() viewCollectionClicked = new EventEmitter<{
readonly: boolean;
tab: CollectionDialogTabType;
}>();
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../../shared/shared.module";
import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module";
import { CollectionNameBadgeComponent } from "./collection-name.badge.component";
@NgModule({
imports: [SharedModule, PipesModule],
declarations: [CollectionNameBadgeComponent],
exports: [CollectionNameBadgeComponent],
})
export class CollectionBadgeModule {}

View File

@@ -0,0 +1,6 @@
<ng-container *ngFor="let c of shownCollections">
<span bitBadge variant="secondary">{{ c | collectionNameFromId: collections }}</span>
</ng-container>
<ng-container *ngIf="showXMore">
<span bitBadge variant="secondary">+ {{ xMoreCount }} more</span>
</ng-container>

View File

@@ -0,0 +1,26 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input } from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common";
@Component({
selector: "app-collection-badge",
templateUrl: "collection-name-badge.component.html",
})
export class CollectionNameBadgeComponent {
@Input() collectionIds: string[];
@Input() collections: CollectionView[];
get shownCollections(): string[] {
return this.showXMore ? this.collectionIds.slice(0, 2) : this.collectionIds;
}
get showXMore(): boolean {
return this.collectionIds.length > 3;
}
get xMoreCount(): number {
return this.collectionIds.length - 2;
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../../shared/shared.module";
import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module";
import { GroupNameBadgeComponent } from "./group-name-badge.component";
@NgModule({
imports: [SharedModule, PipesModule],
declarations: [GroupNameBadgeComponent],
exports: [GroupNameBadgeComponent],
})
export class GroupBadgeModule {}

View File

@@ -0,0 +1 @@
<bit-badge-list [items]="groupNames" [maxItems]="3" variant="secondary"></bit-badge-list>

View File

@@ -0,0 +1,29 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnChanges } from "@angular/core";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GroupView } from "../../core";
@Component({
selector: "app-group-badge",
templateUrl: "group-name-badge.component.html",
})
export class GroupNameBadgeComponent implements OnChanges {
@Input() selectedGroups: SelectionReadOnlyRequest[];
@Input() allGroups: GroupView[];
protected groupNames: string[] = [];
constructor(private i18nService: I18nService) {}
ngOnChanges() {
this.groupNames = this.selectedGroups
.map((g) => {
return this.allGroups.find((o) => o.id === g.id)?.name;
})
.sort(this.i18nService.collator.compare);
}
}

View File

@@ -0,0 +1,136 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from "@angular/core";
import { firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService, ToastService } from "@bitwarden/components";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
import {
VaultFilterList,
VaultFilterSection,
VaultFilterType,
} from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter-section.type";
import { CollectionFilter } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter.type";
@Component({
selector: "app-organization-vault-filter",
templateUrl:
"../../../../vault/individual-vault/vault-filter/components/vault-filter.component.html",
})
export class VaultFilterComponent
extends BaseVaultFilterComponent
implements OnInit, OnDestroy, OnChanges
{
@Input() set organization(value: Organization) {
if (value && value !== this._organization) {
this._organization = value;
this.vaultFilterService.setOrganizationFilter(this._organization);
}
}
_organization: Organization;
protected destroy$: Subject<void>;
constructor(
protected vaultFilterService: VaultFilterService,
protected policyService: PolicyService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected toastService: ToastService,
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
) {
super(
vaultFilterService,
policyService,
i18nService,
platformUtilsService,
toastService,
billingApiService,
dialogService,
configService,
);
}
async ngOnInit() {
this.filters = await this.buildAllFilters();
if (!this.activeFilter.selectedCipherTypeNode) {
this.activeFilter.resetFilter();
this.activeFilter.selectedCollectionNode =
(await this.getDefaultFilter()) as TreeNode<CollectionFilter>;
}
this.isLoaded = true;
}
async ngOnChanges(changes: SimpleChanges) {
if (changes.organization) {
this.filters = await this.buildAllFilters();
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async removeCollapsibleCollection() {
const collapsedNodes = await firstValueFrom(this.vaultFilterService.collapsedFilterNodes$);
collapsedNodes.delete("AllCollections");
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes);
}
protected async addCollectionFilter(): Promise<VaultFilterSection> {
// Ensure the Collections filter is never collapsed for the org vault
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.removeCollapsibleCollection();
const collectionFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree(
{
id: "AllCollections",
name: "collections",
type: "all",
icon: "bwi-collection",
},
[
{
id: "AllCollections",
name: "Collections",
type: "all",
icon: "bwi-collection",
},
],
),
header: {
showHeader: false,
isSelectable: true,
},
action: this.applyCollectionFilter,
};
return collectionFilterSection;
}
async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList;
builderFilter.typeFilter = await this.addTypeFilter(["favorites"]);
builderFilter.collectionFilter = await this.addCollectionFilter();
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;
}
async getDefaultFilter(): Promise<TreeNode<VaultFilterType>> {
return await firstValueFrom(this.filters?.collectionFilter.data$);
}
}

View File

@@ -0,0 +1,22 @@
import { NgModule } from "@angular/core";
import { SearchModule } from "@bitwarden/components";
import { VaultFilterService as VaultFilterServiceAbstraction } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilterSharedModule } from "../../../../vault/individual-vault/vault-filter/shared/vault-filter-shared.module";
import { VaultFilterComponent } from "./vault-filter.component";
import { VaultFilterService } from "./vault-filter.service";
@NgModule({
imports: [VaultFilterSharedModule, SearchModule],
declarations: [VaultFilterComponent],
exports: [VaultFilterComponent],
providers: [
{
provide: VaultFilterServiceAbstraction,
useClass: VaultFilterService,
},
],
})
export class VaultFilterModule {}

View File

@@ -0,0 +1,58 @@
import { Injectable, OnDestroy } from "@angular/core";
import { map, Observable, ReplaySubject, Subject } from "rxjs";
import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { VaultFilterService as BaseVaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/vault-filter.service";
import { CollectionFilter } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter.type";
@Injectable()
export class VaultFilterService extends BaseVaultFilterService implements OnDestroy {
private destroy$ = new Subject<void>();
private _collections = new ReplaySubject<CollectionAdminView[]>(1);
filteredCollections$: Observable<CollectionAdminView[]> = this._collections.asObservable();
collectionTree$: Observable<TreeNode<CollectionFilter>> = this.filteredCollections$.pipe(
map((collections) => this.buildCollectionTree(collections)),
);
constructor(
organizationService: OrganizationService,
folderService: FolderService,
cipherService: CipherService,
policyService: PolicyService,
i18nService: I18nService,
stateProvider: StateProvider,
collectionService: CollectionService,
accountService: AccountService,
) {
super(
organizationService,
folderService,
cipherService,
policyService,
i18nService,
stateProvider,
collectionService,
accountService,
);
}
async reloadCollections(collections: CollectionAdminView[]) {
this._collections.next(collections);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,151 @@
<app-header [title]="title" [icon]="icon">
<bit-breadcrumbs *ngIf="showBreadcrumbs" slot="breadcrumbs">
<bit-breadcrumb
[route]="[]"
[queryParams]="{ organizationId: organization.id, collectionId: null }"
queryParamsHandling="merge"
>
{{ organization.name }}
<span>
{{ "collections" | i18n | lowercase }}
</span>
</bit-breadcrumb>
<ng-container>
<bit-breadcrumb
*ngFor="let collection of collections"
icon="bwi-collection"
[route]="[]"
[queryParams]="{ collectionId: collection.id }"
queryParamsHandling="merge"
>
{{ collection.name }}
</bit-breadcrumb>
</ng-container>
</bit-breadcrumbs>
<ng-container slot="title-suffix">
<ng-container
*ngIf="
collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo)
"
>
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
size="small"
type="button"
aria-haspopup="true"
></button>
<bit-menu #editCollectionMenu>
<ng-container *ngIf="canEditCollection">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, false)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, false)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="!canEditCollection && canViewCollectionInfo">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, true)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, true)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</ng-container>
<small *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</small>
</ng-container>
<bit-search
*ngIf="organization?.isProviderUser && !organization?.isMember"
class="tw-grow"
[ngModel]="searchText"
(ngModelChange)="onSearchTextChanged($event)"
[placeholder]="'searchCollection' | i18n"
></bit-search>
<div
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned && organization"
class="tw-shrink-0"
>
<!-- "New" menu is always shown unless the user cannot create a cipher and cannot create a collection-->
<ng-container *ngIf="canCreateCipher || canCreateCollection">
<div appListDropdown>
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="addOptions"
id="newItemDropdown"
appA11yTitle="{{ 'new' | i18n }}"
>
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}<i class="bwi tw-ml-2" aria-hidden="true"></i>
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">
<ng-container *ngIf="canCreateCipher">
<button type="button" bitMenuItem (click)="addCipher(CipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Identity)">
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SecureNote)">
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="canCreateCollection">
<bit-menu-divider *ngIf="canCreateCipher"></bit-menu-divider>
<button type="button" bitMenuItem (click)="addCollection()">
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "collection" | i18n }}
</button>
</ng-container>
</bit-menu>
</div>
</ng-container>
</div>
</app-header>

View File

@@ -0,0 +1,248 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// FIXME: rename output bindings and then remove this line
/* eslint-disable @angular-eslint/no-output-on-prefix */
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import {
CollectionAdminService,
CollectionAdminView,
Unassigned,
} from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import {
BreadcrumbsModule,
DialogService,
MenuModule,
SearchModule,
SimpleDialogOptions,
} from "@bitwarden/components";
import { HeaderModule } from "../../../../layouts/header/header.module";
import { SharedModule } from "../../../../shared";
import { CollectionDialogTabType } from "../../../../vault/components/collection-dialog";
import {
All,
RoutedVaultFilterModel,
} from "../../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
@Component({
standalone: true,
selector: "app-org-vault-header",
templateUrl: "./vault-header.component.html",
imports: [
CommonModule,
MenuModule,
SharedModule,
BreadcrumbsModule,
HeaderModule,
SearchModule,
JslibModule,
],
})
export class VaultHeaderComponent {
protected All = All;
protected Unassigned = Unassigned;
/**
* Boolean to determine the loading state of the header.
* Shows a loading spinner if set to true
*/
@Input() loading: boolean;
/** Current active filter */
@Input() filter: RoutedVaultFilterModel;
/** The organization currently being viewed */
@Input() organization: Organization;
/** Currently selected collection */
@Input() collection?: TreeNode<CollectionAdminView>;
/** The current search text in the header */
@Input() searchText: string;
/** Emits an event when the new item button is clicked in the header */
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
/** Emits an event when the new collection button is clicked in the header */
@Output() onAddCollection = new EventEmitter<void>();
/** Emits an event when the edit collection button is clicked in the header */
@Output() onEditCollection = new EventEmitter<{
tab: CollectionDialogTabType;
readonly: boolean;
}>();
/** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>();
/** Emits an event when the search text changes in the header*/
@Output() searchTextChanged = new EventEmitter<string>();
protected CollectionDialogTabType = CollectionDialogTabType;
/** The cipher type enum. */
protected CipherType = CipherType;
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
private collectionAdminService: CollectionAdminService,
private router: Router,
) {}
get title() {
const headerType = this.i18nService.t("collections").toLowerCase();
if (this.collection != null) {
return this.collection.node.name;
}
if (this.filter.collectionId === Unassigned) {
return this.i18nService.t("unassigned");
}
return this.organization?.name
? `${this.organization?.name} ${headerType}`
: this.i18nService.t("collections");
}
get icon() {
return this.filter.collectionId !== undefined ? "bwi-collection" : "";
}
protected get showBreadcrumbs() {
return this.filter.collectionId !== undefined && this.filter.collectionId !== All;
}
/**
* A list of collection filters that form a chain from the organization root to currently selected collection.
* Begins from the organization root and excludes the currently selected collection.
*/
protected get collections() {
if (this.collection == undefined) {
return [];
}
const collections = [this.collection];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections
.slice(1)
.reverse()
.map((treeNode) => treeNode.node);
}
private showFreeOrgUpgradeDialog(): void {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
this.organization.canEditSubscription
? "freeOrgMaxCollectionReachedManageBilling"
: "freeOrgMaxCollectionReachedNoManageBilling",
this.organization.maxCollections,
),
type: "primary",
};
if (this.organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
firstValueFrom(simpleDialog.closed).then((result: boolean | undefined) => {
if (!result) {
return;
}
if (result && this.organization.canEditSubscription) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
});
}
get canEditCollection(): boolean {
// Only edit collections if not editing "Unassigned"
if (this.collection === undefined) {
return false;
}
// Otherwise, check if we can edit the specified collection
return this.collection.node.canEdit(this.organization);
}
addCipher(cipherType?: CipherType) {
this.onAddCipher.emit(cipherType);
}
async addCollection() {
if (this.organization.productTierType === ProductTierType.Free) {
const collections = await this.collectionAdminService.getAll(this.organization.id);
if (collections.length === this.organization.maxCollections) {
this.showFreeOrgUpgradeDialog();
return;
}
}
this.onAddCollection.emit();
}
async editCollection(tab: CollectionDialogTabType, readonly: boolean): Promise<void> {
this.onEditCollection.emit({ tab, readonly });
}
get canDeleteCollection(): boolean {
// Only delete collections if not deleting "Unassigned"
if (this.collection === undefined) {
return false;
}
// Otherwise, check if we can delete the specified collection
return this.collection.node.canDelete(this.organization);
}
get canViewCollectionInfo(): boolean {
return this.collection.node.canViewCollectionInfo(this.organization);
}
get canCreateCollection(): boolean {
return this.organization?.canCreateNewCollections;
}
get canCreateCipher(): boolean {
if (this.organization?.isProviderUser && !this.organization?.isMember) {
return false;
}
return true;
}
deleteCollection() {
this.onDeleteCollection.emit();
}
onSearchTextChanged(t: string) {
this.searchText = t;
this.searchTextChanged.emit(t);
}
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessVaultTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { VaultComponent } from "./vault.component";
const routes: Routes = [
{
path: "",
component: VaultComponent,
canActivate: [organizationPermissionsGuard(canAccessVaultTab)],
data: { titleId: "vaults" },
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class VaultRoutingModule {}

View File

@@ -0,0 +1,153 @@
<ng-container *ngIf="freeTrial$ | async as freeTrial">
<bit-banner
id="free-trial-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="premium"
[showClose]="false"
*ngIf="!refreshing && freeTrial.shownBanner"
>
{{ freeTrial.message }}
<a
bitLink
linkType="secondary"
(click)="navigateToPaymentMethod()"
class="tw-cursor-pointer"
rel="noreferrer noopener"
>
{{ "clickHereToAddPaymentMethod" | i18n }}
</a>
</bit-banner>
</ng-container>
<ng-container *ngIf="resellerWarning$ | async as resellerWarning">
<bit-banner
id="reseller-warning-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
icon="bwi-billing"
bannerType="info"
[showClose]="false"
*ngIf="!refreshing"
>
{{ resellerWarning?.message }}
</bit-banner>
</ng-container>
<app-org-vault-header
[filter]="filter"
[loading]="refreshing"
[organization]="organization"
[collection]="selectedCollection"
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher($event)"
(onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header>
<div class="row">
<div class="col-3" *ngIf="!hideVaultFilters">
<div class="groupings">
<div class="content">
<div class="inner-content">
<app-organization-vault-filter
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
</div>
</div>
</div>
<div [class]="hideVaultFilters ? 'col-12' : 'col-9'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"
(selectedChange)="addAccessToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<bit-toggle [value]="0">
{{ "all" | i18n }}
</bit-toggle>
<bit-toggle [value]="1">
{{ "addAccess" | i18n }}
</bit-toggle>
</bit-toggle-group>
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<app-vault-items
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="organization ? [organization] : []"
[allGroups]="allGroups"
[disabled]="loading"
[showOwner]="false"
[showPermissionsColumn]="true"
[showCollections]="filter.type !== undefined"
[showGroups]="
organization?.useGroups &&
((filter.type === undefined && filter.collectionId === undefined) ||
filter.collectionId !== undefined)
"
[showPremiumFeatures]="organization?.useTotp"
[showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.canAccessEventLogs"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="true"
[showBulkAddToCollections]="true"
[viewingOrgVault]="true"
[addAccessStatus]="addAccessStatus$ | async"
[addAccessToggle]="showAddAccessToggle"
[activeCollection]="selectedCollection?.node"
>
</app-vault-items>
<ng-container *ngIf="!performingInitialLoad && isEmpty">
<bit-no-items *ngIf="!showCollectionAccessRestricted">
<span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span>
<button
slot="button"
bitButton
(click)="addCipher()"
buttonType="primary"
type="button"
*ngIf="
filter.type !== 'trash' &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
"
>
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button>
</bit-no-items>
<collection-access-restricted
*ngIf="showCollectionAccessRestricted"
[canEditCollection]="selectedCollection?.node?.canEdit(organization)"
[canViewCollectionInfo]="selectedCollection?.node?.canViewCollectionInfo(organization)"
(viewCollectionClicked)="
editCollection(selectedCollection.node, $event.tab, $event.readonly)
"
>
</collection-access-restricted>
</ng-container>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="performingInitialLoad"
>
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
</div>
</div>
<ng-template #attachments></ng-template>
<ng-template #cipherAddEdit></ng-template>
<ng-template #collectionsModal></ng-template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule } from "../../../shared/loose-components.module";
import { SharedModule } from "../../../shared/shared.module";
import { CollectionDialogModule } from "../../../vault/components/collection-dialog";
import { OrganizationBadgeModule } from "../../../vault/individual-vault/organization-badge/organization-badge.module";
import { ViewComponent } from "../../../vault/individual-vault/view.component";
import { CollectionBadgeModule } from "./collection-badge/collection-badge.module";
import { GroupBadgeModule } from "./group-badge/group-badge.module";
import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component";
@NgModule({
imports: [
VaultRoutingModule,
SharedModule,
LooseComponentsModule,
GroupBadgeModule,
CollectionBadgeModule,
OrganizationBadgeModule,
CollectionDialogModule,
VaultComponent,
ViewComponent,
],
})
export class VaultModule {}

View File

@@ -14,14 +14,14 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
import { organizationRedirectGuard } from "../../admin-console/organizations/guards/org-redirect.guard";
import { OrganizationLayoutComponent } from "../../admin-console/organizations/layouts/organization-layout.component";
import { deepLinkGuard } from "../../auth/guards/deep-link.guard";
import { VaultModule } from "../../vault/org-vault/vault.module";
import { VaultModule } from "./collections/vault.module";
import { isEnterpriseOrgGuard } from "./guards/is-enterprise-org.guard";
import { organizationPermissionsGuard } from "./guards/org-permissions.guard";
import { organizationRedirectGuard } from "./guards/org-redirect.guard";
import { AdminConsoleIntegrationsComponent } from "./integrations/integrations.component";
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
import { GroupsComponent } from "./manage/groups.component";
const routes: Routes = [