1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 00:33:44 +00:00

Revert "[EC-73] edit collection modal (#3638)"

This reverts commit 39655ebe29.
This commit is contained in:
Shane Melton
2022-11-22 10:08:19 -08:00
parent 39655ebe29
commit cbb22230fc
57 changed files with 420 additions and 1225 deletions

View File

@@ -15,7 +15,7 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { PreloadedEnglishI18nModule } from "../../../../tests/preloaded-english-i18n.module"; import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent, PermissionMode } from "./access-selector.component"; import { AccessSelectorComponent, PermissionMode } from "./access-selector.component";
import { AccessItemType, CollectionPermission } from "./access-selector.models"; import { AccessItemType, CollectionPermission } from "./access-selector.models";

View File

@@ -2,7 +2,7 @@ import { OrganizationUserStatusType } from "@bitwarden/common/enums/organization
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { CollectionAccessSelectionView } from "../../../core"; import { CollectionAccessSelectionView } from "../../views/collection-access-selection.view";
/** /**
* Permission options that replace/correspond with readOnly and hidePassword server fields. * Permission options that replace/correspond with readOnly and hidePassword server fields.

View File

@@ -1,6 +1,6 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { SharedModule } from "../../../../shared/shared.module"; import { SharedModule } from "../../../shared";
import { AccessSelectorComponent } from "./access-selector.component"; import { AccessSelectorComponent } from "./access-selector.component";
import { UserTypePipe } from "./user-type.pipe"; import { UserTypePipe } from "./user-type.pipe";

View File

@@ -15,7 +15,7 @@ import {
TabsModule, TabsModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../../tests/preloaded-english-i18n.module"; import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent } from "./access-selector.component"; import { AccessSelectorComponent } from "./access-selector.component";
import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models"; import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models";

View File

@@ -1,4 +0,0 @@
import { NgModule } from "@angular/core";
@NgModule({})
export class CoreOrganizationModule {}

View File

@@ -1,4 +0,0 @@
export * from "./core-organization.module";
export * from "./services/collection-admin.service";
export * from "./views/collection-access-selection-view";
export * from "./views/collection-admin-view";

View File

@@ -1,123 +0,0 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { EncString } from "@bitwarden/common/models/domain/enc-string";
import { CollectionRequest } from "@bitwarden/common/models/request/collection.request";
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
import {
CollectionAccessDetailsResponse,
CollectionResponse,
} from "@bitwarden/common/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { CoreOrganizationModule } from "../core-organization.module";
import { CollectionAdminView } from "../views/collection-admin-view";
@Injectable({ providedIn: CoreOrganizationModule })
export class CollectionAdminService {
constructor(private apiService: ApiService, private cryptoService: CryptoService) {}
async getAll(organizationId: string): Promise<CollectionView[]> {
const collectionResponse = await this.apiService.getCollections(organizationId);
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
return [];
}
return await this.decryptMany(organizationId, collectionResponse.data);
}
async get(
organizationId: string,
collectionId: string
): Promise<CollectionAdminView | undefined> {
const collectionResponse = await this.apiService.getCollectionDetails(
organizationId,
collectionId
);
if (collectionResponse == null) {
return undefined;
}
const [view] = await this.decryptMany(organizationId, [collectionResponse]);
return view;
}
async save(collection: CollectionAdminView): Promise<unknown> {
const request = await this.encrypt(collection);
let response: CollectionResponse;
if (collection.id == null) {
response = await this.apiService.postCollection(collection.organizationId, request);
collection.id = response.id;
} else {
response = await this.apiService.putCollection(
collection.organizationId,
collection.id,
request
);
}
// TODO: Implement upsert when in PS-1083: Collection Service refactors
// await this.collectionService.upsert(data);
return;
}
async delete(organizationId: string, collectionId: string): Promise<void> {
await this.apiService.deleteCollection(organizationId, collectionId);
}
private async decryptMany(
organizationId: string,
collections: CollectionResponse[] | CollectionAccessDetailsResponse[]
): Promise<CollectionAdminView[]> {
const orgKey = await this.cryptoService.getOrgKey(organizationId);
const promises = collections.map(async (c) => {
const view = new CollectionAdminView();
view.id = c.id;
view.name = await this.cryptoService.decryptToUtf8(new EncString(c.name), orgKey);
view.externalId = c.externalId;
view.organizationId = c.organizationId;
if (isCollectionAccessDetailsResponse(c)) {
view.groups = c.groups;
view.users = c.users;
}
return view;
});
return await Promise.all(promises);
}
private async encrypt(model: CollectionAdminView): Promise<CollectionRequest> {
if (model.organizationId == null) {
throw new Error("Collection has no organization id.");
}
const key = await this.cryptoService.getOrgKey(model.organizationId);
if (key == null) {
throw new Error("No key for this collection's organization.");
}
const collection = new CollectionRequest();
collection.externalId = model.externalId;
collection.name = (await this.cryptoService.encrypt(model.name, key)).encryptedString;
collection.groups = model.groups.map(
(group) => new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords)
);
collection.users = model.users.map(
(user) => new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords)
);
return collection;
}
}
function isCollectionAccessDetailsResponse(
response: CollectionResponse | CollectionAccessDetailsResponse
): response is CollectionAccessDetailsResponse {
const anyResponse = response as any;
return anyResponse?.groups instanceof Array && anyResponse?.users instanceof Array;
}

View File

@@ -1,25 +0,0 @@
import { View } from "@bitwarden/common/models/view/view";
interface SelectionResponseLike {
id: string;
readOnly: boolean;
hidePasswords: boolean;
}
export class CollectionAccessSelectionView extends View {
readonly id: string;
readonly readOnly: boolean;
readonly hidePasswords: boolean;
constructor(response?: SelectionResponseLike) {
super();
if (!response) {
return;
}
this.id = response.id;
this.readOnly = response.readOnly;
this.hidePasswords = response.hidePasswords;
}
}

View File

@@ -1,25 +0,0 @@
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/models/response/collection.response";
import { CollectionAccessSelectionView } from "./collection-access-selection-view";
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = [];
constructor(response?: CollectionAccessDetailsResponse) {
super(response);
if (!response) {
return;
}
this.groups = response.groups
? response.groups.map((g) => new CollectionAccessSelectionView(g))
: [];
this.users = response.users
? response.users.map((g) => new CollectionAccessSelectionView(g))
: [];
}
}

View File

@@ -0,0 +1,162 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="collectionAddEditTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="modal-header">
<h1 class="modal-title" id="collectionAddEditTitle">{{ title }}</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="modal-body" *ngIf="!loading">
<div class="form-group">
<label for="name">{{ "name" | i18n }}</label>
<input
id="name"
class="form-control"
type="text"
name="Name"
[(ngModel)]="name"
required
appAutofocus
[disabled]="!this.canSave"
/>
</div>
<div class="form-group">
<label for="externalId">{{ "externalId" | i18n }}</label>
<input
id="externalId"
class="form-control"
type="text"
name="ExternalId"
[(ngModel)]="externalId"
[disabled]="!this.canSave"
/>
<small class="form-text text-muted">{{ "externalIdDesc" | i18n }}</small>
</div>
<ng-container *ngIf="accessGroups">
<h3 class="mt-4 d-flex mb-0">
{{ "groupAccess" | i18n }}
<div class="ml-auto" *ngIf="groups && groups.length && this.canSave">
<button type="button" (click)="selectAll(true)" class="btn btn-link btn-sm py-0">
{{ "selectAll" | i18n }}
</button>
<button type="button" (click)="selectAll(false)" class="btn btn-link btn-sm py-0">
{{ "unselectAll" | i18n }}
</button>
</div>
</h3>
<div *ngIf="!groups || !groups.length">
{{ "noGroupsInList" | i18n }}
</div>
<table class="table table-hover table-list mb-0" *ngIf="groups && groups.length">
<thead>
<tr>
<th>&nbsp;</th>
<th>{{ "name" | i18n }}</th>
<th width="100" class="text-center">{{ "hidePasswords" | i18n }}</th>
<th width="100" class="text-center">{{ "readOnly" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let g of groups; let i = index">
<td class="table-list-checkbox" (click)="check(g)">
<input
type="checkbox"
[(ngModel)]="g.checked"
name="Groups[{{ i }}].Checked"
[disabled]="g.accessAll || !this.canSave"
appStopProp
/>
</td>
<td (click)="check(g)">
{{ g.name }}
<ng-container *ngIf="g.accessAll">
<i
class="bwi bwi-filter text-muted bwi-fw"
title="{{ 'groupAccessAllItems' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "groupAccessAllItems" | i18n }}</span>
</ng-container>
</td>
<td class="text-center">
<input
type="checkbox"
[(ngModel)]="g.hidePasswords"
name="Groups[{{ i }}].HidePasswords"
[disabled]="!g.checked || g.accessAll || !this.canSave"
/>
</td>
<td class="text-center">
<input
type="checkbox"
[(ngModel)]="g.readOnly"
name="Groups[{{ i }}].ReadOnly"
[disabled]="!g.checked || g.accessAll || !this.canSave"
/>
</td>
</tr>
</tbody>
</table>
</ng-container>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary btn-submit"
[disabled]="form.loading"
*ngIf="this.canSave"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
<div class="ml-auto" *ngIf="this.canDelete">
<button
#deleteBtn
type="button"
(click)="delete()"
class="btn btn-outline-danger"
appA11yTitle="{{ 'delete' | i18n }}"
*ngIf="editMode"
[disabled]="deleteBtn.loading"
[appApiAction]="deletePromise"
>
<i
class="bwi bwi-trash bwi-lg bwi-fw"
[hidden]="deleteBtn.loading"
aria-hidden="true"
></i>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!deleteBtn.loading"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</button>
</div>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,181 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Utils } from "@bitwarden/common/misc/utils";
import { EncString } from "@bitwarden/common/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
import { CollectionRequest } from "@bitwarden/common/models/request/collection.request";
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
import { GroupServiceAbstraction } from "../services/abstractions/group";
import { GroupView } from "../views/group.view";
@Component({
selector: "app-collection-add-edit",
templateUrl: "collection-add-edit.component.html",
})
export class CollectionAddEditComponent implements OnInit {
@Input() collectionId: string;
@Input() organizationId: string;
@Input() canSave: boolean;
@Input() canDelete: boolean;
@Output() onSavedCollection = new EventEmitter();
@Output() onDeletedCollection = new EventEmitter();
loading = true;
editMode = false;
accessGroups = false;
title: string;
name: string;
externalId: string;
groups: GroupView[] = [];
formPromise: Promise<any>;
deletePromise: Promise<any>;
private orgKey: SymmetricCryptoKey;
constructor(
private apiService: ApiService,
private groupApiService: GroupServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService,
private logService: LogService,
private organizationService: OrganizationService
) {}
async ngOnInit() {
const organization = await this.organizationService.get(this.organizationId);
this.accessGroups = organization.useGroups;
this.editMode = this.loading = this.collectionId != null;
if (this.accessGroups) {
const groupsResponse = await this.groupApiService.getAll(this.organizationId);
this.groups = groupsResponse.sort(Utils.getSortFunction(this.i18nService, "name"));
}
this.orgKey = await this.cryptoService.getOrgKey(this.organizationId);
if (this.editMode) {
this.editMode = true;
this.title = this.i18nService.t("editCollection");
try {
const collection = await this.apiService.getCollectionDetails(
this.organizationId,
this.collectionId
);
this.name = await this.cryptoService.decryptToUtf8(
new EncString(collection.name),
this.orgKey
);
this.externalId = collection.externalId;
if (collection.groups != null && this.groups.length > 0) {
collection.groups.forEach((s) => {
const group = this.groups.filter((g) => !g.accessAll && g.id === s.id);
if (group != null && group.length > 0) {
(group[0] as any).checked = true;
(group[0] as any).readOnly = s.readOnly;
(group[0] as any).hidePasswords = s.hidePasswords;
}
});
}
} catch (e) {
this.logService.error(e);
}
} else {
this.title = this.i18nService.t("addCollection");
}
this.groups.forEach((g) => {
if (g.accessAll) {
(g as any).checked = true;
}
});
this.loading = false;
}
check(g: GroupView, select?: boolean) {
if (g.accessAll) {
return;
}
(g as any).checked = select == null ? !(g as any).checked : select;
if (!(g as any).checked) {
(g as any).readOnly = false;
(g as any).hidePasswords = false;
}
}
selectAll(select: boolean) {
this.groups.forEach((g) => this.check(g, select));
}
async submit() {
if (this.orgKey == null) {
throw new Error("No encryption key for this organization.");
}
const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(this.name, this.orgKey)).encryptedString;
request.externalId = this.externalId;
request.groups = this.groups
.filter((g) => (g as any).checked && !g.accessAll)
.map(
(g) => new SelectionReadOnlyRequest(g.id, !!(g as any).readOnly, !!(g as any).hidePasswords)
);
try {
if (this.editMode) {
this.formPromise = this.apiService.putCollection(
this.organizationId,
this.collectionId,
request
);
} else {
this.formPromise = this.apiService.postCollection(this.organizationId, request);
}
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(this.editMode ? "editedCollectionId" : "createdCollectionId", this.name)
);
this.onSavedCollection.emit();
} catch (e) {
this.logService.error(e);
}
}
async delete() {
if (!this.editMode) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteCollectionConfirmation"),
this.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
this.deletePromise = this.apiService.deleteCollection(this.organizationId, this.collectionId);
await this.deletePromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollectionId", this.name)
);
this.onDeletedCollection.emit();
} catch (e) {
this.logService.error(e);
}
}
}

View File

@@ -65,16 +65,6 @@
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i> <i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button> </button>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right">
<a
class="dropdown-item"
href="#"
appStopClick
*ngIf="this.canEdit(c)"
(click)="edit(c)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</a>
<a <a
class="dropdown-item" class="dropdown-item"
href="#" href="#"

View File

@@ -1,7 +1,5 @@
import { Overlay } from "@angular/cdk/overlay";
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { first } from "rxjs/operators"; import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -21,10 +19,8 @@ import {
} from "@bitwarden/common/models/response/collection.response"; } from "@bitwarden/common/models/response/collection.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { CollectionView } from "@bitwarden/common/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { DialogService } from "@bitwarden/components";
import { CollectionDialogResult, openCollectionDialog } from "../shared";
import { CollectionAddEditComponent } from "./collection-add-edit.component";
import { EntityUsersComponent } from "./entity-users.component"; import { EntityUsersComponent } from "./entity-users.component";
@Component({ @Component({
@@ -60,9 +56,7 @@ export class CollectionsComponent implements OnInit {
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private searchService: SearchService, private searchService: SearchService,
private logService: LogService, private logService: LogService,
private organizationService: OrganizationService, private organizationService: OrganizationService
private dialogService: DialogService,
private overlay: Overlay
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -122,24 +116,36 @@ export class CollectionsComponent implements OnInit {
this.didScroll = this.pagedCollections.length > this.pageSize; this.didScroll = this.pagedCollections.length > this.pageSize;
} }
async edit(collection?: CollectionView) { async edit(collection: CollectionView) {
const canCreate = collection == undefined && this.canCreate; const canCreate = collection == null && this.canCreate;
const canEdit = collection != undefined && this.canEdit(collection); const canEdit = collection != null && this.canEdit(collection);
const canDelete = collection != undefined && this.canDelete(collection); const canDelete = collection != null && this.canDelete(collection);
if (!(canCreate || canEdit || canDelete)) { if (!(canCreate || canEdit || canDelete)) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions")); this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
return; return;
} }
const dialog = openCollectionDialog(this.dialogService, this.overlay, { const [modal] = await this.modalService.openViewRef(
data: { collectionId: collection?.id, organizationId: this.organizationId }, CollectionAddEditComponent,
}); this.addEditModalRef,
(comp) => {
const result = await lastValueFrom(dialog.closed); comp.organizationId = this.organizationId;
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { comp.collectionId = collection != null ? collection.id : null;
this.load(); comp.canSave = canCreate || canEdit;
} comp.canDelete = canDelete;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onSavedCollection.subscribe(() => {
modal.close();
this.load();
});
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onDeletedCollection.subscribe(() => {
modal.close();
this.removeCollection(collection);
});
}
);
} }
add() { add() {

View File

@@ -15,7 +15,6 @@ import { CollectionDetailsResponse } from "@bitwarden/common/models/response/col
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { GroupServiceAbstraction } from "../services/abstractions/group";
import { import {
AccessItemType, AccessItemType,
AccessItemValue, AccessItemValue,
@@ -23,7 +22,8 @@ import {
convertToPermission, convertToPermission,
convertToSelectionView, convertToSelectionView,
PermissionMode, PermissionMode,
} from "../shared/components/access-selector"; } from "../components/access-selector";
import { GroupServiceAbstraction } from "../services/abstractions/group";
import { GroupView } from "../views/group.view"; import { GroupView } from "../views/group.view";
/** /**
@@ -179,7 +179,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams, @Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>, private dialogRef: DialogRef<GroupAddEditDialogResultType>,
private apiService: ApiService, private apiService: ApiService,
private groupApiService: GroupServiceAbstraction,
private groupService: GroupServiceAbstraction, private groupService: GroupServiceAbstraction,
private i18nService: I18nService, private i18nService: I18nService,
private collectionService: CollectionService, private collectionService: CollectionService,

View File

@@ -48,6 +48,16 @@ type CollectionViewMap = {
}; };
type GroupDetailsRow = { type GroupDetailsRow = {
/**
* Group Id (used for searching)
*/
id: string;
/**
* Group name (used for searching)
*/
name: string;
/** /**
* Details used for displaying group information * Details used for displaying group information
*/ */

View File

@@ -10,9 +10,7 @@ import {
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard"; import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component"; import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
import { CollectionsComponent } from "./manage/collections.component";
import { GroupsComponent } from "./manage/groups.component"; import { GroupsComponent } from "./manage/groups.component";
import { ManageComponent } from "./manage/manage.component";
import { PeopleComponent } from "./manage/people.component"; import { PeopleComponent } from "./manage/people.component";
import { VaultModule } from "./vault/vault.module"; import { VaultModule } from "./vault/vault.module";
@@ -52,19 +50,6 @@ const routes: Routes = [
organizationPermissions: canAccessGroupsTab, organizationPermissions: canAccessGroupsTab,
}, },
}, },
{
path: "manage",
component: ManageComponent,
children: [
{
path: "collections",
component: CollectionsComponent,
data: {
titleId: "collections",
},
},
],
},
{ {
path: "reporting", path: "reporting",
loadChildren: () => loadChildren: () =>

View File

@@ -2,29 +2,25 @@ import { NgModule } from "@angular/core";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { SharedModule } from "../shared/shared.module"; import { SharedModule } from "../shared";
import { CoreOrganizationModule } from "./core"; import { AccessSelectorModule } from "./components/access-selector";
import { CollectionAddEditComponent } from "./manage/collection-add-edit.component";
import { GroupAddEditComponent } from "./manage/group-add-edit.component"; import { GroupAddEditComponent } from "./manage/group-add-edit.component";
import { GroupsComponent } from "./manage/groups.component"; import { GroupsComponent } from "./manage/groups.component";
import { UserGroupsComponent } from "./manage/user-groups.component"; import { UserGroupsComponent } from "./manage/user-groups.component";
import { OrganizationsRoutingModule } from "./organization-routing.module"; import { OrganizationsRoutingModule } from "./organization-routing.module";
import { GroupServiceAbstraction } from "./services/abstractions/group"; import { GroupServiceAbstraction } from "./services/abstractions/group";
import { GroupService } from "./services/group/group.service"; import { GroupService } from "./services/group/group.service";
import { SharedOrganizationModule } from "./shared";
import { AccessSelectorModule } from "./shared/components/access-selector";
@NgModule({ @NgModule({
imports: [ imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule],
SharedModule, declarations: [
OrganizationsRoutingModule, GroupsComponent,
SharedOrganizationModule, GroupAddEditComponent,
CoreOrganizationModule, CollectionAddEditComponent,
SharedModule, UserGroupsComponent,
AccessSelectorModule,
OrganizationsRoutingModule,
], ],
declarations: [GroupsComponent, GroupAddEditComponent, UserGroupsComponent],
providers: [ providers: [
{ {
provide: GroupServiceAbstraction, provide: GroupServiceAbstraction,

View File

@@ -1,94 +0,0 @@
<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">
<bit-tab label="{{ 'collectionInfo' | i18n }}">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput formControlName="name" required />
</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>
<select bitInput formControlName="parent">
<option [ngValue]="null">-</option>
<option *ngIf="deletedParentName" disabled [ngValue]="deletedParentName">
{{ deletedParentName }} ({{ "deleted" | i18n }})
</option>
<option *ngFor="let collection of nestOptions" [ngValue]="collection.name">
{{ collection.name }}
</option>
</select>
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'access' | i18n }}">
<bit-access-selector
*ngIf="organization.useGroups"
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="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>

View File

@@ -1,266 +0,0 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Overlay } from "@angular/cdk/overlay";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { takeUntil, Subject, of, combineLatest, shareReplay, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { GroupServiceAbstraction } from "@bitwarden/common/abstractions/group/group.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { GroupView } from "@bitwarden/common/models/view/group-view";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/src/models/response/organization-user.response";
import { CollectionView } from "@bitwarden/common/src/models/view/collection.view";
import { BitValidators, DialogService } from "@bitwarden/components";
import { CollectionAdminView, CollectionAdminService } from "../../../core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
convertToPermission,
convertToSelectionView,
} from "../access-selector";
export interface CollectionDialogParams {
collectionId?: string;
organizationId: 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 loading = true;
protected organization?: Organization;
protected collection?: CollectionView;
protected nestOptions: CollectionView[] = [];
protected accessItems: AccessItemView[] = [];
protected deletedParentName: string | undefined;
protected formGroup = this.formBuilder.group({
name: ["", BitValidators.forbiddenCharacters(["/"])],
externalId: "",
parent: null as string | null,
access: [[] as AccessItemValue[]],
});
constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<CollectionDialogResult>,
private apiService: ApiService,
private organizationService: OrganizationService,
private groupService: GroupServiceAbstraction,
private collectionService: CollectionAdminService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService
) {}
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.apiService.getOrganizationUsers(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 !== null && !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;
}
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 () => {
if (this.formGroup.invalid) {
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.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.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("/") : null;
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,
overlay: Overlay,
config: DialogConfig<CollectionDialogParams>
) {
return dialogService.open<CollectionDialogResult, CollectionDialogParams>(
CollectionDialogComponent,
{
positionStrategy: overlay.position().global().centerHorizontally().top(),
...config,
}
);
}

View File

@@ -1,13 +0,0 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../../shared/shared.module";
import { AccessSelectorModule } from "../access-selector";
import { CollectionDialogComponent } from "./collection-dialog.component";
@NgModule({
imports: [SharedModule, AccessSelectorModule],
declarations: [CollectionDialogComponent],
exports: [CollectionDialogComponent],
})
export class CollectionDialogModule {}

View File

@@ -1,245 +0,0 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Provider } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { action } from "@storybook/addon-actions";
import { Meta, Story, moduleMetadata } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { GroupServiceAbstraction } from "@bitwarden/common/abstractions/group";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
import { Utils } from "@bitwarden/common/misc/utils";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/models/response/organization-user.response";
import { GroupView } from "@bitwarden/common/models/view/group-view";
import { PlatformUtilsService } from "@bitwarden/common/src/abstractions/platformUtils.service";
import { SharedModule } from "../../../../shared/shared.module";
import { PreloadedEnglishI18nModule } from "../../../../tests/preloaded-english-i18n.module";
import {
CollectionAccessSelectionView,
CollectionAdminView,
CollectionAdminService,
} from "../../../core";
import { AccessSelectorModule } from "../access-selector";
import { CollectionDialogComponent, CollectionDialogParams } from "./collection-dialog.component";
interface ProviderData {
collectionId: string;
organizationId: string;
collections: CollectionAdminView[];
groups: GroupView[];
users: OrganizationUserUserDetailsResponse[];
useGroups: boolean;
}
export default {
title: "Web/Organizations/Collection Dialog",
component: CollectionDialogComponent,
decorators: [
moduleMetadata({
imports: [
JslibModule,
PreloadedEnglishI18nModule,
SharedModule,
ReactiveFormsModule,
AccessSelectorModule,
],
}),
],
} as Meta;
const organizationId = Utils.newGuid();
const groups = Array.from({ length: 10 }, (x, i) => createGroup(`Group ${i}`));
const users = Array.from({ length: 10 }, (x, i) => createUser(i));
const groupSelection = new CollectionAccessSelectionView({
id: groups[0].id,
readOnly: false,
hidePasswords: false,
});
const userSelection = new CollectionAccessSelectionView({
id: users[0].id,
readOnly: false,
hidePasswords: false,
});
let collections = Array.from({ length: 10 }, (x, i) =>
createCollection(`Collection ${i}`, [groupSelection], [userSelection])
);
collections = collections.concat(
collections.map((c, i) =>
createCollection(`${c.name}/Sub-collection ${i}`, [groupSelection], [userSelection])
)
);
function providers(data: ProviderData) {
return [
{
provide: DIALOG_DATA,
useValue: {
collectionId: data.collectionId,
organizationId: data.organizationId,
} as CollectionDialogParams,
},
{
provide: DialogRef,
useClass: MockDialogRef,
},
{
provide: CollectionAdminService,
useValue: new MockCollectionAdminService(data.collections, data.collectionId),
},
{
provide: OrganizationService,
useValue: {
get: () => ({ useGroups: data.useGroups, canDeleteAssignedCollections: true } as any),
} as Partial<OrganizationService>,
},
{
provide: GroupServiceAbstraction,
useValue: { getAll: () => Promise.resolve(data.groups) } as Partial<GroupServiceAbstraction>,
},
{
provide: ApiService,
useValue: {
getOrganizationUsers: () => Promise.resolve({ data: data.users }),
},
},
{
provide: PlatformUtilsService,
useValue: {
showDialog: action("PlatformUtilsService.show") as () => Promise<unknown>,
} as Partial<PlatformUtilsService>,
},
] as Provider[];
}
class MockDialogRef implements Partial<DialogRef> {
close = action("DialogRef.close");
}
class MockCollectionAdminService implements Partial<CollectionAdminService> {
constructor(private collections: CollectionAdminView[], private collectionId: string) {}
private saveAction = action("CollectionApiService.save");
getAll = () => Promise.resolve(this.collections);
get = () => Promise.resolve(this.collections.find((c) => c.id === this.collectionId));
async save(...args: unknown[]) {
this.saveAction(args);
await Utils.delay(1500);
}
}
function createCollection(
name: string,
collectionGroups: CollectionAccessSelectionView[],
collectionUsers: CollectionAccessSelectionView[],
id = Utils.newGuid()
) {
const collection = new CollectionAdminView();
collection.id = id;
collection.name = name;
collection.groups = collectionGroups;
collection.users = collectionUsers;
return collection;
}
function createGroup(name: string, id = Utils.newGuid()) {
const group = new GroupView();
group.id = id;
group.name = name;
return group;
}
function createUser(i: number, id = Utils.newGuid()) {
const user = new OrganizationUserUserDetailsResponse({});
user.name = `User ${i}`;
user.email = `user_${i}@email.com`;
user.twoFactorEnabled = false;
user.usesKeyConnector = false;
user.status = OrganizationUserStatusType.Accepted;
return user;
}
const NewCollectionTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => ({
moduleMetadata: {
providers: providers({
collectionId: undefined,
organizationId,
collections,
groups,
users,
useGroups: true,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
});
export const NewCollection = NewCollectionTemplate.bind({});
const ExistingCollectionTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => ({
moduleMetadata: {
providers: providers({
collectionId: collections[collections.length - 1].id,
organizationId,
collections,
groups,
users,
useGroups: true,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
});
export const ExistingCollection = ExistingCollectionTemplate.bind({});
const NonExistingParentTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => {
const collection = createCollection(
"Non existing parent/Collection",
[groupSelection],
[userSelection]
);
return {
moduleMetadata: {
providers: providers({
collectionId: collection.id,
organizationId,
collections: [collection, ...collections],
groups,
users,
useGroups: true,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
};
};
export const NonExistingParentCollection = NonExistingParentTemplate.bind({});
const FreeOrganizationTemplate: Story<CollectionDialogComponent> = (
args: CollectionDialogComponent
) => ({
moduleMetadata: {
providers: providers({
collectionId: collections[collections.length - 1].id,
organizationId,
collections,
groups,
users,
useGroups: false,
}),
},
template: `<app-collection-dialog></app-collection-dialog>`,
});
export const FreeOrganization = FreeOrganizationTemplate.bind({});

View File

@@ -1,2 +0,0 @@
export * from "./collection-dialog.component";
export * from "./collection-dialog.module";

View File

@@ -1,2 +0,0 @@
export * from "./shared-organization.module";
export * from "./components/collection-dialog";

View File

@@ -1,9 +0,0 @@
import { NgModule } from "@angular/core";
import { CollectionDialogModule } from "./components/collection-dialog";
@NgModule({
imports: [CollectionDialogModule],
exports: [CollectionDialogModule],
})
export class SharedOrganizationModule {}

View File

@@ -123,7 +123,7 @@ import { FolderAddEditComponent } from "../vault/folder-add-edit.component";
import { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module"; import { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module";
import { ShareComponent } from "../vault/share.component"; import { ShareComponent } from "../vault/share.component";
import { SharedModule } from "./shared.module"; import { SharedModule } from ".";
// Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left. // Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left.
// If you are building new functionality, please create or extend a feature module instead. // If you are building new functionality, please create or extend a feature module instead.

View File

@@ -10,6 +10,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { import {
AsyncActionsModule, AsyncActionsModule,
AvatarModule, AvatarModule,
BadgeListModule,
BadgeModule, BadgeModule,
ButtonModule, ButtonModule,
CalloutModule, CalloutModule,
@@ -22,7 +23,6 @@ import {
MultiSelectModule, MultiSelectModule,
TableModule, TableModule,
TabsModule, TabsModule,
BadgeListModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
// Register the locales for the application // Register the locales for the application
@@ -39,7 +39,6 @@ import "./locales";
imports: [ imports: [
CommonModule, CommonModule,
DragDropModule, DragDropModule,
DialogModule,
FormsModule, FormsModule,
InfiniteScrollModule, InfiniteScrollModule,
JslibModule, JslibModule,
@@ -67,7 +66,6 @@ import "./locales";
CommonModule, CommonModule,
AsyncActionsModule, AsyncActionsModule,
DragDropModule, DragDropModule,
DialogModule,
FormsModule, FormsModule,
InfiniteScrollModule, InfiniteScrollModule,
JslibModule, JslibModule,
@@ -82,7 +80,6 @@ import "./locales";
MultiSelectModule, MultiSelectModule,
FormFieldModule, FormFieldModule,
IconModule, IconModule,
IconButtonModule,
TabsModule, TabsModule,
TableModule, TableModule,
AvatarModule, AvatarModule,

View File

@@ -2396,13 +2396,10 @@
"message": "Warning! This user requires Key Connector to manage their encryption. Removing this user from your organization will permanently deactivate their account. This action cannot be undone. Do you want to proceed?" "message": "Warning! This user requires Key Connector to manage their encryption. Removing this user from your organization will permanently deactivate their account. This action cannot be undone. Do you want to proceed?"
}, },
"externalId": { "externalId": {
"message": "External ID" "message": "External id"
}, },
"externalIdDesc": { "externalIdDesc": {
"message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector and API." "message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector or API."
},
"nestCollectionUnder": {
"message": "Nest collection under"
}, },
"accessControl": { "accessControl": {
"message": "Access control" "message": "Access control"
@@ -2425,12 +2422,6 @@
"editCollection": { "editCollection": {
"message": "Edit collection" "message": "Edit collection"
}, },
"collectionInfo": {
"message": "Collection info"
},
"access": {
"message": "Access"
},
"deleteCollectionConfirmation": { "deleteCollectionConfirmation": {
"message": "Are you sure you want to delete this collection?" "message": "Are you sure you want to delete this collection?"
}, },
@@ -5463,15 +5454,6 @@
} }
} }
}, },
"inputForbiddenCharacters": {
"message": "The following characters are not allowed: $CHARACTERS$",
"placeholders": {
"characters": {
"content": "$1",
"example": "@, #, $, %"
}
}
},
"inputMaxLength": { "inputMaxLength": {
"message": "Input must not exceed $COUNT$ characters in length.", "message": "Input must not exceed $COUNT$ characters in length.",
"placeholders": { "placeholders": {
@@ -5565,6 +5547,9 @@
"accessAllCollectionsHelp": { "accessAllCollectionsHelp": {
"message": "If checked, this will replace all other collection permissions." "message": "If checked, this will replace all other collection permissions."
}, },
"selectMembers": {
"message": "Select members"
},
"selectCollections": { "selectCollections": {
"message": "Select collections" "message": "Select collections"
}, },
@@ -5603,26 +5588,5 @@
}, },
"memberAccessAll": { "memberAccessAll": {
"message": "This member can access and modify all items." "message": "This member can access and modify all items."
},
"membersColumnHeader": {
"message": "Member/Group"
},
"groupAndMemberColumnHeader": {
"message": "Member"
},
"selectGroupsAndMembers": {
"message": "Select groups and members"
},
"selectMembers": {
"message": "Select members"
},
"userPermissionOverrideHelper": {
"message": "Permissions set for a member will replace permissions set by that member's group"
},
"noMembersOrGroupsAdded": {
"message": "No members or groups added"
},
"deleted": {
"message": "Deleted"
} }
} }

View File

@@ -28,7 +28,6 @@ import {
InternalFolderService, InternalFolderService,
} from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "@bitwarden/common/abstractions/formValidationErrors.service"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "@bitwarden/common/abstractions/formValidationErrors.service";
import { GroupServiceAbstraction } from "@bitwarden/common/abstractions/group";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/abstractions/keyConnector.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/abstractions/keyConnector.service";
import { LogService } from "@bitwarden/common/abstractions/log.service"; import { LogService } from "@bitwarden/common/abstractions/log.service";
@@ -89,7 +88,6 @@ import { FileUploadService } from "@bitwarden/common/services/fileUpload.service
import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service"; import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/services/folder/folder.service"; import { FolderService } from "@bitwarden/common/services/folder/folder.service";
import { FormValidationErrorsService } from "@bitwarden/common/services/formValidationErrors.service"; import { FormValidationErrorsService } from "@bitwarden/common/services/formValidationErrors.service";
import { GroupService } from "@bitwarden/common/services/group/group.service";
import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service"; import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
import { LoginService } from "@bitwarden/common/services/login.service"; import { LoginService } from "@bitwarden/common/services/login.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service";
@@ -582,11 +580,6 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
useClass: ValidationService, useClass: ValidationService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction],
}, },
{
provide: GroupServiceAbstraction,
useClass: GroupService,
deps: [ApiServiceAbstraction],
},
{ {
provide: LoginServiceAbstraction, provide: LoginServiceAbstraction,
useClass: LoginService, useClass: LoginService,

View File

@@ -92,7 +92,7 @@ import { BillingPaymentResponse } from "../models/response/billing-payment.respo
import { BreachAccountResponse } from "../models/response/breach-account.response"; import { BreachAccountResponse } from "../models/response/breach-account.response";
import { CipherResponse } from "../models/response/cipher.response"; import { CipherResponse } from "../models/response/cipher.response";
import { import {
CollectionAccessDetailsResponse, CollectionGroupDetailsResponse,
CollectionResponse, CollectionResponse,
} from "../models/response/collection.response"; } from "../models/response/collection.response";
import { DeviceVerificationResponse } from "../models/response/device-verification.response"; import { DeviceVerificationResponse } from "../models/response/device-verification.response";
@@ -314,7 +314,7 @@ export abstract class ApiService {
getCollectionDetails: ( getCollectionDetails: (
organizationId: string, organizationId: string,
id: string id: string
) => Promise<CollectionAccessDetailsResponse>; ) => Promise<CollectionGroupDetailsResponse>;
getUserCollections: () => Promise<ListResponse<CollectionResponse>>; getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>; getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>; getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>;

View File

@@ -1,9 +0,0 @@
import { GroupView } from "../../models/view/group-view";
export class GroupServiceAbstraction {
delete: (orgId: string, groupId: string) => Promise<void>;
deleteMany: (orgId: string, groupIds: string[]) => Promise<GroupView[]>;
get: (orgId: string, groupId: string) => Promise<GroupView>;
getAll: (orgId: string) => Promise<GroupView[]>;
}

View File

@@ -1,2 +0,0 @@
export * from "./group.service.abstraction";
export * from "./responses/group-response";

View File

@@ -1,31 +0,0 @@
import { BaseResponse } from "../../../models/response/base.response";
import { SelectionReadOnlyResponse } from "../../../models/response/selection-read-only.response";
export class GroupResponse extends BaseResponse {
id: string;
organizationId: string;
name: string;
accessAll: boolean;
externalId: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.accessAll = this.getResponseProperty("AccessAll");
this.externalId = this.getResponseProperty("ExternalId");
}
}
export class GroupDetailsResponse extends GroupResponse {
collections: SelectionReadOnlyResponse[] = [];
constructor(response: any) {
super(response);
const collections = this.getResponseProperty("Collections");
if (collections != null) {
this.collections = collections.map((c: any) => new SelectionReadOnlyResponse(c));
}
}
}

View File

@@ -440,10 +440,6 @@ export class Utils {
return mobile || win.navigator.userAgent.match(/iPad/i) != null; return mobile || win.navigator.userAgent.match(/iPad/i) != null;
} }
static delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private static isAppleMobile(win: Window) { private static isAppleMobile(win: Window) {
return ( return (
win.navigator.userAgent.match(/iPhone/i) != null || win.navigator.userAgent.match(/iPhone/i) != null ||

View File

@@ -6,7 +6,6 @@ export class CollectionRequest {
name: string; name: string;
externalId: string; externalId: string;
groups: SelectionReadOnlyRequest[] = []; groups: SelectionReadOnlyRequest[] = [];
users: SelectionReadOnlyRequest[] = [];
constructor(collection?: Collection) { constructor(collection?: Collection) {
if (collection == null) { if (collection == null) {

View File

@@ -25,9 +25,8 @@ export class CollectionDetailsResponse extends CollectionResponse {
} }
} }
export class CollectionAccessDetailsResponse extends CollectionResponse { export class CollectionGroupDetailsResponse extends CollectionResponse {
groups: SelectionReadOnlyResponse[] = []; groups: SelectionReadOnlyResponse[] = [];
users: SelectionReadOnlyResponse[] = [];
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@@ -35,10 +34,5 @@ export class CollectionAccessDetailsResponse extends CollectionResponse {
if (groups != null) { if (groups != null) {
this.groups = groups.map((g: any) => new SelectionReadOnlyResponse(g)); this.groups = groups.map((g: any) => new SelectionReadOnlyResponse(g));
} }
const users = this.getResponseProperty("Users");
if (users != null) {
this.users = users.map((g: any) => new SelectionReadOnlyResponse(g));
}
} }
} }

View File

@@ -1,6 +1,6 @@
import { Collection } from "../domain/collection"; import { Collection } from "../domain/collection";
import { ITreeNodeObject } from "../domain/tree-node"; import { ITreeNodeObject } from "../domain/tree-node";
import { CollectionResponse } from "../response/collection.response"; import { CollectionGroupDetailsResponse } from "../response/collection.response";
import { View } from "./view"; import { View } from "./view";
@@ -12,7 +12,7 @@ export class CollectionView implements View, ITreeNodeObject {
readOnly: boolean = null; readOnly: boolean = null;
hidePasswords: boolean = null; hidePasswords: boolean = null;
constructor(c?: Collection | CollectionResponse) { constructor(c?: Collection | CollectionGroupDetailsResponse) {
if (!c) { if (!c) {
return; return;
} }

View File

@@ -1,17 +0,0 @@
import { GroupResponse } from "../../abstractions/group";
import { SelectionReadOnlyResponse } from "../response/selection-read-only.response";
import { View } from "./view";
export class GroupView implements View {
id: string;
organizationId: string;
name: string;
accessAll: boolean;
externalId: string;
collections: SelectionReadOnlyResponse[] = [];
static fromResponse(response: GroupResponse) {
return Object.assign(new GroupView(), response);
}
}

View File

@@ -100,7 +100,7 @@ import { BillingPaymentResponse } from "../models/response/billing-payment.respo
import { BreachAccountResponse } from "../models/response/breach-account.response"; import { BreachAccountResponse } from "../models/response/breach-account.response";
import { CipherResponse } from "../models/response/cipher.response"; import { CipherResponse } from "../models/response/cipher.response";
import { import {
CollectionAccessDetailsResponse, CollectionGroupDetailsResponse,
CollectionResponse, CollectionResponse,
} from "../models/response/collection.response"; } from "../models/response/collection.response";
import { DeviceVerificationResponse } from "../models/response/device-verification.response"; import { DeviceVerificationResponse } from "../models/response/device-verification.response";
@@ -810,7 +810,7 @@ export class ApiService implements ApiServiceAbstraction {
async getCollectionDetails( async getCollectionDetails(
organizationId: string, organizationId: string,
id: string id: string
): Promise<CollectionAccessDetailsResponse> { ): Promise<CollectionGroupDetailsResponse> {
const r = await this.send( const r = await this.send(
"GET", "GET",
"/organizations/" + organizationId + "/collections/" + id + "/details", "/organizations/" + organizationId + "/collections/" + id + "/details",
@@ -818,7 +818,7 @@ export class ApiService implements ApiServiceAbstraction {
true, true,
true true
); );
return new CollectionAccessDetailsResponse(r); return new CollectionGroupDetailsResponse(r);
} }
async getUserCollections(): Promise<ListResponse<CollectionResponse>> { async getUserCollections(): Promise<ListResponse<CollectionResponse>> {

View File

@@ -1,65 +0,0 @@
import { ApiService } from "../../abstractions/api.service";
import {
GroupDetailsResponse,
GroupResponse,
GroupServiceAbstraction,
} from "../../abstractions/group";
import { ListResponse } from "../../models/response/list.response";
import { GroupView } from "../../models/view/group-view";
import { OrganizationGroupBulkRequest } from "./requests/organization-group-bulk-request";
export class GroupService implements GroupServiceAbstraction {
constructor(private apiService: ApiService) {}
async delete(orgId: string, groupId: string): Promise<void> {
await this.apiService.send(
"DELETE",
"/organizations/" + orgId + "/groups/" + groupId,
null,
true,
false
);
}
async deleteMany(orgId: string, groupIds: string[]): Promise<GroupView[]> {
const request = new OrganizationGroupBulkRequest(groupIds);
const r = await this.apiService.send(
"DELETE",
"/organizations/" + orgId + "/groups",
request,
true,
true
);
const listResponse = new ListResponse(r, GroupResponse);
return listResponse.data?.map((gr) => GroupView.fromResponse(gr)) ?? [];
}
async get(orgId: string, groupId: string): Promise<GroupView> {
const r = await this.apiService.send(
"GET",
"/organizations/" + orgId + "/groups/" + groupId + "/details",
null,
true,
true
);
return GroupView.fromResponse(new GroupDetailsResponse(r));
}
async getAll(orgId: string): Promise<GroupView[]> {
const r = await this.apiService.send(
"GET",
"/organizations/" + orgId + "/groups",
null,
true,
true
);
const listResponse = new ListResponse(r, GroupDetailsResponse);
return listResponse.data?.map((gr) => GroupView.fromResponse(gr)) ?? [];
}
}

View File

@@ -1,7 +0,0 @@
export class OrganizationGroupBulkRequest {
ids: string[];
constructor(ids: string[]) {
this.ids = ids == null ? [] : ids;
}
}

View File

@@ -3,9 +3,10 @@ import { Subject, takeUntil } from "rxjs";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { BitActionDirective } from "./bit-action.directive";
import { BitSubmitDirective } from "./bit-submit.directive"; import { BitSubmitDirective } from "./bit-submit.directive";
import { BitActionDirective } from ".";
/** /**
* This directive has two purposes: * This directive has two purposes:
* *

View File

@@ -1,6 +1,6 @@
import { Component, Input, OnChanges } from "@angular/core"; import { Component, Input, OnChanges } from "@angular/core";
import { BadgeType } from "../badge"; import { BadgeTypes } from "../badge";
@Component({ @Component({
selector: "bit-badge-list", selector: "bit-badge-list",
@@ -12,7 +12,7 @@ export class BadgeListComponent implements OnChanges {
protected filteredItems: string[] = []; protected filteredItems: string[] = [];
protected isFiltered = false; protected isFiltered = false;
@Input() badgeType: BadgeType = "primary"; @Input() badgeType: BadgeTypes = "primary";
@Input() items: string[] = []; @Input() items: string[] = [];
@Input() @Input()

View File

@@ -1,8 +1,8 @@
import { Directive, ElementRef, HostBinding, Input } from "@angular/core"; import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
export type BadgeType = "primary" | "secondary" | "success" | "danger" | "warning" | "info"; export type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
const styles: Record<BadgeType, string[]> = { const styles: Record<BadgeTypes, string[]> = {
primary: ["tw-bg-primary-500"], primary: ["tw-bg-primary-500"],
secondary: ["tw-bg-text-muted"], secondary: ["tw-bg-text-muted"],
success: ["tw-bg-success-500"], success: ["tw-bg-success-500"],
@@ -11,7 +11,7 @@ const styles: Record<BadgeType, string[]> = {
info: ["tw-bg-info-500"], info: ["tw-bg-info-500"],
}; };
const hoverStyles: Record<BadgeType, string[]> = { const hoverStyles: Record<BadgeTypes, string[]> = {
primary: ["hover:tw-bg-primary-700"], primary: ["hover:tw-bg-primary-700"],
secondary: ["hover:tw-bg-secondary-700"], secondary: ["hover:tw-bg-secondary-700"],
success: ["hover:tw-bg-success-700"], success: ["hover:tw-bg-success-700"],
@@ -47,7 +47,7 @@ export class BadgeDirective {
.concat(this.hasHoverEffects ? hoverStyles[this.badgeType] : []); .concat(this.hasHoverEffects ? hoverStyles[this.badgeType] : []);
} }
@Input() badgeType: BadgeType = "primary"; @Input() badgeType: BadgeTypes = "primary";
private hasHoverEffects = false; private hasHoverEffects = false;

View File

@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Meta, moduleMetadata, Story } from "@storybook/angular"; import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { BadgeDirective, BadgeType } from "./badge.directive"; import { BadgeDirective } from "./badge.directive";
export default { export default {
title: "Component Library/Badge", title: "Component Library/Badge",
@@ -15,12 +15,6 @@ export default {
args: { args: {
badgeType: "primary", badgeType: "primary",
}, },
argTypes: {
badgeType: {
options: ["primary", "secondary", "success", "danger", "warning", "info"] as BadgeType[],
control: { type: "inline-radio" },
},
},
parameters: { parameters: {
design: { design: {
type: "figma", type: "figma",

View File

@@ -1,2 +1,2 @@
export { BadgeDirective, BadgeType } from "./badge.directive"; export { BadgeDirective, BadgeTypes } from "./badge.directive";
export * from "./badge.module"; export * from "./badge.module";

View File

@@ -4,7 +4,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { I18nMockService } from "../utils/i18n-mock.service"; import { I18nMockService } from "../utils/i18n-mock.service";
import { CalloutComponent } from "./callout.component"; import { CalloutComponent } from ".";
describe("Callout", () => { describe("Callout", () => {
let component: CalloutComponent; let component: CalloutComponent;

View File

@@ -1,56 +0,0 @@
import { FormsModule, ReactiveFormsModule, FormBuilder } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { InputModule } from "../input/input.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { forbiddenCharacters } from "./bit-validators/forbidden-characters.validator";
import { BitFormFieldComponent } from "./form-field.component";
import { FormFieldModule } from "./form-field.module";
export default {
title: "Component Library/Form/Custom Validators",
component: BitFormFieldComponent,
decorators: [
moduleMetadata({
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
inputForbiddenCharacters: (chars) =>
`The following characters are not allowed: ${chars}`,
});
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689",
},
},
} as Meta;
const template = `
<form [formGroup]="formObj">
<bit-form-field>
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
</form>`;
export const ForbiddenCharacters: Story<BitFormFieldComponent> = (args: BitFormFieldComponent) => ({
props: {
formObj: new FormBuilder().group({
name: ["", forbiddenCharacters(["\\", "/", "@", "#", "$", "%", "^", "&", "*", "(", ")"])],
}),
},
template,
});

View File

@@ -1,45 +0,0 @@
import { FormControl } from "@angular/forms";
import { forbiddenCharacters } from "./forbidden-characters.validator";
describe("forbiddenCharacters", () => {
it("should return no error when input is null", () => {
const input = createControl(null);
const validate = forbiddenCharacters(["n", "u", "l", "l"]);
const errors = validate(input);
expect(errors).toBe(null);
});
it("should return no error when no characters are forbidden", () => {
const input = createControl("special characters: \\/@#$%^&*()");
const validate = forbiddenCharacters([]);
const errors = validate(input);
expect(errors).toBe(null);
});
it("should return no error when input does not contain forbidden characters", () => {
const input = createControl("contains no special characters");
const validate = forbiddenCharacters(["\\", "/", "@", "#", "$", "%", "^", "&", "*", "(", ")"]);
const errors = validate(input);
expect(errors).toBe(null);
});
it("should return error when input contains forbidden characters", () => {
const input = createControl("contains / illegal @ characters");
const validate = forbiddenCharacters(["\\", "/", "@", "#", "$", "%", "^", "&", "*", "(", ")"]);
const errors = validate(input);
expect(errors).not.toBe(null);
});
});
function createControl(input: string) {
return new FormControl(input);
}

View File

@@ -1,23 +0,0 @@
import { AbstractControl, FormControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export function forbiddenCharacters(characters: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!(control instanceof FormControl)) {
throw new Error("forbiddenCharacters only supports validating FormControls");
}
if (control.value === null || control.value === undefined) {
return null;
}
const value = String(control.value);
for (const char of value) {
if (characters.includes(char)) {
return { forbiddenCharacters: { value: control.value, characters } };
}
}
return null;
};
}

View File

@@ -1 +0,0 @@
export { forbiddenCharacters } from "./forbidden-characters.validator";

View File

@@ -30,8 +30,6 @@ export class BitErrorComponent {
return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength); return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength);
case "maxlength": case "maxlength":
return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength); return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength);
case "forbiddenCharacters":
return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", "));
default: default:
// Attempt to show a custom error message. // Attempt to show a custom error message.
if (this.error[1]?.message) { if (this.error[1]?.message) {

View File

@@ -1,4 +1,3 @@
export * from "./form-field.module"; export * from "./form-field.module";
export * from "./form-field.component"; export * from "./form-field.component";
export * from "./form-field-control"; export * from "./form-field-control";
export * as BitValidators from "./bit-validators";

View File

@@ -1,2 +1 @@
export * from "./multi-select.module"; export * from "./multi-select.module";
export * from "./models/select-item-view";