mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
[EC-73] edit collection modal (#3638)
* [EC-16] Cleanup RxJS linting problems
* [EC-16] Update Group tab to use table component and show collections.
* [EC-16] Extract interface from GroupResponse and use it in the view
* [EC-16] Remove heading underline
* [EC-16] Cleanup i18n
* [EC-16] More i18n cleanup
* [EC-16] Fix bulk group request type name
* [EC-16] Rename group details type
* [EC-73] feat: add inital version of modal using dialog service
* [EC-73] feat: create story for dialog
* [EC-73] feat: setup story with support for injected data
* [EC-73] feat: add inital version of subtitle
* [EC-73] feat: add tabs
* [EC-73] feat: initial version of collection info form
* [EC-73] feat: start of working form
* [EC-73] feat: add custom form validator
* [EC-73] fix: dialog directive names after rebase
* [EC-73] feat: use custom validator
* [EC-73] fix: story
* [EC-73] feat: allow parent picking
* [EC-73] feat: remove tabs to allow for merging
* [EC-73] feat: extend story with new and edit dialogs
* [EC-73] feat: change title depending on if editing or not
* [EC-73] fix: parent not connected to form
* [EC-73] feat: add organizationId to dialog data
* [EC-73] feat: only allow nesting within collections with access
* [EC-73] feat: handle loading with spinner
* [EC-73] feat: update collections on submit
* [EC-73] feat: reload on save
* [EC-73] feat: update story to work with latest changes
* [EC-73] feat: always fetch collections from server
* [EC-73] fix: do not submit if form invalid
* [EC-73] feat: create new collections using new ui
* [EC-73] fix: external id not being saved
* [EC-73] chore: move calls to separete collection admin service
* [EC-73] feat: use new admin views
* [EC-73] feat: implement deletion
* [EC-73] feat: add support for collection details in service
* [EC-73] fix: story
* [EC-73] fix: cancel button
* [EC-73] feat: re-add tabs
* [EC-73] fix: jslib service collection deps
* [EC-73] chore: rename component to collection-dialog
* [EC-73] chore: clean up collection api service which was replaced
* [EC-73] chore: restore collection.service
* [EC-73] chore: restore dialog component changes
* [EC-73] fix: move subscription to ngOnInit
* [EC-73] feat: disable padding when using tabbed content
* [EC-73] fix: new lint rules after merge
* Add Access Selector Component and Stories
* Cherry pick FormSelectionList
* Fix some problems caused from cherry-pick
* Fix some Web module problems caused from cherry-pick
* Move AccessSelector out of the root components directory.
Move UserType pipe to AccessSelectorModule
* Fix broken member access selector story
* Add organization feature module
* Undo changes to messages.json
* Fix messages.json
* Remove redundant CommonModule
* [EC-86] Clear collectionMap before populating it with new collections
* [EC-86] Update initialization/loading logic to make better use of the Observable pattern
* [EC-86] Make table cells use a pointer cursor
* [EC-86] Use bitIconButton for row menu triggers
* [EC-86] Refactor GroupDetailsRow interface to wrap GroupDetailsResponse.
Remove response model interfaces.
Cleanup GroupsComponent.
* [EC-86] Add bit-badge-list component and tweak BadgeModule to support both the component and directive.
Update mockI18nService to support templated strings.
* [EC-86] Cleanup badge color and bitIconButton classes
* [EC-86] Cleanup more styles
* [EC-86] Add GroupApiService
Add a new GroupApiService to replace Group Api calls in the ApiService.
* [EC-599] Fix avatar/icon sizing
* [EC-599] Remove padding in permission column
* [EC-599] Make FormSelectionList operations immutable
* [EC-599] Integrate the multi-select component
* [EC-599] Handle readonly/access all edge cases
* [EC-599] Add initial unit tests
Also cleans up public interface for the AccessSelectorComponent. Fixes a bug found during unit test creation.
* [EC-599] Include item name in control labels
* [EC-599] Cleanup member email display
* [EC-86] Revisions for badge-list implementation.
- Remove `| null` for maxItems according to ADR-0014
- Remove custom setter for items
- Use ngOnChanges to update filteredItems
- Fix sr-only tailwind class and show screen reader comma after last item if truncated.
* [EC-86] Refactor badge-list module/component
- Move the badge list component to its own module.
- Extract badge list stories from badge stories.
- Cleanup bade stories and module after refactor.
* [EC-86] Refactor/rename GroupApiService
- Re-name GroupApiService to GroupService
as there is no need for a separate Api service (no sync or local data for admin services)
- Add GroupView for use in the GroupService instead of raw API models
- Update views to use GroupView instead of raw GroupResponse models
* [EC-86] Refactor group API request models
- Move organizationGroupBulkRequest to group requests folder
- Fix relative imports in GroupService
* [EC-86] Fix linting errors
* Fix tab item text color
Tab item text color broke after a merge from master and needs a fix to account for bootstrap styles in Web.
* [EC-599] Review suggestions
- Change PermissionMode to Enum
- Rename permControl to permissionControl to be more clear
- Rename FormSelectionList file to kebab case.
- Move permission row boolean logic to named function for readability
* [EC-599] Cleanup AccessSelectorComponent tests
- Clarify test states
- Add tests for column rendering
- Add tests for permission mode
- Add id to column headers for testing
- Fix small permissionControl bug found during testing
* [EC-599] Add FormSelectionList unit tests
* [EC-73] chore: re-add collections page
* [EC-86] Rename new files using kebab-case
* [EC-73] chore: move component to shared org module
* Fix MultiSelect component styles and CSP error (#3841)
* Update Web styles and CSP to support MultiSelect component
- Include the MultiSelect module in the CL barrel file of exports
- Import the MultiSelect scss into the Web styles.scss
- Add the necessary sha256 hash to webpack CSP policy to support ngSelect inline styles
* Undo removal of 127.0.0.1 from webpack CSP
(cherry picked from commit 3ed1221f7f
)
* [EC-73] feat: add empty access selector
* [EC-73] feat: add groups to access selector
* [EC-73] chore: improve storybook support
* [EC-73] feat: tweak item assignment
* [EC-73] feat: add support for showing users
* [EC-73] feat: use async actions
* [EC-73] chore: clean up casting
* [EC-73] fix: permissions not loading correctly in access selector
* [EC-73] feat: implement saving group permissions
* [EC-73] feat: rename to collection access selection view
* [EC-73] feat: save users as well
* [EC-73] fix: access selector usage
* [EC-73] feat: new collection creation
* [EC-73] feat: fetch users from collection details
* [EC-73] chore: clean up
* [EC-73] fix: circular dependency issues
* [EC-73] fix: import shared module directly to workaround build issues
* [EC-73] fix: missing dependencies in story
* [EC-73] chore: move story
* [EC-73] fix: manual cherry pick permission bug fix
* [EC-73] feat: hide delete button if no permission
* [EC-73] feat: properly handle orgs without groups
* [EC-73] fix: use correct functions in template
* [EC-73] feat: properly handle non-existing parent
* [EC-73] chore: use double ngIf instead of else template
* [EC-73] fix: add type to dialog ref
* [EC-73] fix: restrict field modifiers
* [EC-73] fix: use result enum directly
* [EC-73] fix: simplify mapping logic
* [EC-73]
* [EC-73] feat: add story for free orgs without groups
* [EC-73] fix: parametrized i18n
* [EC-73] feat: create new shared org module
* [EC-73] feat: move collection dialog to shared
* [EC-73] feat: move access selector to shared
* [EC-73] feat: create core organization module
* [EC-73] feat: move collection admin service to web
* [EC-73] feat: move collection admin views to web
* [EC-73] fix: missing i18n
* [EC-73] fix: refactor for type safety
* [EC-73] fix: storybook not compiling again
* [EC-73] feat: use helper function to open dialog
* [EC-73] chore: remove comment
* [EC-73] fix: revert permission fix
* [EC-73] fix: only show delete if in edit mode
* [EC-73] chore: remove ngIf else in template
* [EC-73] fix: add missing appA11yTitle
* [EC-73] chore: rename remove to delete
* [EC-73] chore: refactor ngOnInit
* [EC-73] fix: dialog position strategy
* [EC-73] fix: revert spinner to old way of doing it
Signed-off-by: Jacob Fink <jfink@bitwarden.com>
Co-authored-by: Shane Melton <smelton@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com>
This commit is contained in:
parent
21a9f84956
commit
39655ebe29
@ -0,0 +1,4 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
@NgModule({})
|
||||
export class CoreOrganizationModule {}
|
4
apps/web/src/app/organizations/core/index.ts
Normal file
4
apps/web/src/app/organizations/core/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./core-organization.module";
|
||||
export * from "./services/collection-admin.service";
|
||||
export * from "./views/collection-access-selection-view";
|
||||
export * from "./views/collection-admin-view";
|
@ -0,0 +1,123 @@
|
||||
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;
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
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))
|
||||
: [];
|
||||
}
|
||||
}
|
@ -1,162 +0,0 @@
|
||||
<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">×</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> </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>
|
@ -1,181 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@ -65,6 +65,16 @@
|
||||
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<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
|
||||
class="dropdown-item"
|
||||
href="#"
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Overlay } from "@angular/cdk/overlay";
|
||||
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
@ -19,8 +21,10 @@ import {
|
||||
} from "@bitwarden/common/models/response/collection.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
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";
|
||||
|
||||
@Component({
|
||||
@ -56,7 +60,9 @@ export class CollectionsComponent implements OnInit {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private searchService: SearchService,
|
||||
private logService: LogService,
|
||||
private organizationService: OrganizationService
|
||||
private organizationService: OrganizationService,
|
||||
private dialogService: DialogService,
|
||||
private overlay: Overlay
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@ -116,36 +122,24 @@ export class CollectionsComponent implements OnInit {
|
||||
this.didScroll = this.pagedCollections.length > this.pageSize;
|
||||
}
|
||||
|
||||
async edit(collection: CollectionView) {
|
||||
const canCreate = collection == null && this.canCreate;
|
||||
const canEdit = collection != null && this.canEdit(collection);
|
||||
const canDelete = collection != null && this.canDelete(collection);
|
||||
async edit(collection?: CollectionView) {
|
||||
const canCreate = collection == undefined && this.canCreate;
|
||||
const canEdit = collection != undefined && this.canEdit(collection);
|
||||
const canDelete = collection != undefined && this.canDelete(collection);
|
||||
|
||||
if (!(canCreate || canEdit || canDelete)) {
|
||||
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
|
||||
return;
|
||||
}
|
||||
|
||||
const [modal] = await this.modalService.openViewRef(
|
||||
CollectionAddEditComponent,
|
||||
this.addEditModalRef,
|
||||
(comp) => {
|
||||
comp.organizationId = this.organizationId;
|
||||
comp.collectionId = collection != null ? collection.id : null;
|
||||
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);
|
||||
});
|
||||
}
|
||||
);
|
||||
const dialog = openCollectionDialog(this.dialogService, this.overlay, {
|
||||
data: { collectionId: collection?.id, organizationId: this.organizationId },
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialog.closed);
|
||||
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
add() {
|
||||
|
@ -15,6 +15,7 @@ import { CollectionDetailsResponse } from "@bitwarden/common/models/response/col
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { GroupServiceAbstraction } from "../services/abstractions/group";
|
||||
import {
|
||||
AccessItemType,
|
||||
AccessItemValue,
|
||||
@ -22,8 +23,7 @@ import {
|
||||
convertToPermission,
|
||||
convertToSelectionView,
|
||||
PermissionMode,
|
||||
} from "../components/access-selector";
|
||||
import { GroupServiceAbstraction } from "../services/abstractions/group";
|
||||
} from "../shared/components/access-selector";
|
||||
import { GroupView } from "../views/group.view";
|
||||
|
||||
/**
|
||||
@ -179,6 +179,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
||||
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
|
||||
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
|
||||
private apiService: ApiService,
|
||||
private groupApiService: GroupServiceAbstraction,
|
||||
private groupService: GroupServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private collectionService: CollectionService,
|
||||
|
@ -48,16 +48,6 @@ type CollectionViewMap = {
|
||||
};
|
||||
|
||||
type GroupDetailsRow = {
|
||||
/**
|
||||
* Group Id (used for searching)
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Group name (used for searching)
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Details used for displaying group information
|
||||
*/
|
||||
|
@ -10,7 +10,9 @@ import {
|
||||
|
||||
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
|
||||
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
|
||||
import { CollectionsComponent } from "./manage/collections.component";
|
||||
import { GroupsComponent } from "./manage/groups.component";
|
||||
import { ManageComponent } from "./manage/manage.component";
|
||||
import { PeopleComponent } from "./manage/people.component";
|
||||
import { VaultModule } from "./vault/vault.module";
|
||||
|
||||
@ -50,6 +52,19 @@ const routes: Routes = [
|
||||
organizationPermissions: canAccessGroupsTab,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "manage",
|
||||
component: ManageComponent,
|
||||
children: [
|
||||
{
|
||||
path: "collections",
|
||||
component: CollectionsComponent,
|
||||
data: {
|
||||
titleId: "collections",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "reporting",
|
||||
loadChildren: () =>
|
||||
|
@ -2,25 +2,29 @@ import { NgModule } from "@angular/core";
|
||||
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
|
||||
import { SharedModule } from "../shared";
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
|
||||
import { AccessSelectorModule } from "./components/access-selector";
|
||||
import { CollectionAddEditComponent } from "./manage/collection-add-edit.component";
|
||||
import { CoreOrganizationModule } from "./core";
|
||||
import { GroupAddEditComponent } from "./manage/group-add-edit.component";
|
||||
import { GroupsComponent } from "./manage/groups.component";
|
||||
import { UserGroupsComponent } from "./manage/user-groups.component";
|
||||
import { OrganizationsRoutingModule } from "./organization-routing.module";
|
||||
import { GroupServiceAbstraction } from "./services/abstractions/group";
|
||||
import { GroupService } from "./services/group/group.service";
|
||||
import { SharedOrganizationModule } from "./shared";
|
||||
import { AccessSelectorModule } from "./shared/components/access-selector";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule],
|
||||
declarations: [
|
||||
GroupsComponent,
|
||||
GroupAddEditComponent,
|
||||
CollectionAddEditComponent,
|
||||
UserGroupsComponent,
|
||||
imports: [
|
||||
SharedModule,
|
||||
OrganizationsRoutingModule,
|
||||
SharedOrganizationModule,
|
||||
CoreOrganizationModule,
|
||||
SharedModule,
|
||||
AccessSelectorModule,
|
||||
OrganizationsRoutingModule,
|
||||
],
|
||||
declarations: [GroupsComponent, GroupAddEditComponent, UserGroupsComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: GroupServiceAbstraction,
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
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 { AccessItemType, CollectionPermission } from "./access-selector.models";
|
@ -2,7 +2,7 @@ import { OrganizationUserStatusType } from "@bitwarden/common/enums/organization
|
||||
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
|
||||
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
|
||||
|
||||
import { CollectionAccessSelectionView } from "../../views/collection-access-selection.view";
|
||||
import { CollectionAccessSelectionView } from "../../../core";
|
||||
|
||||
/**
|
||||
* Permission options that replace/correspond with readOnly and hidePassword server fields.
|
@ -1,6 +1,6 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { SharedModule } from "../../../../shared/shared.module";
|
||||
|
||||
import { AccessSelectorComponent } from "./access-selector.component";
|
||||
import { UserTypePipe } from "./user-type.pipe";
|
@ -15,7 +15,7 @@ import {
|
||||
TabsModule,
|
||||
} 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 { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models";
|
@ -0,0 +1,94 @@
|
||||
<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>
|
@ -0,0 +1,266 @@
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
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 {}
|
@ -0,0 +1,245 @@
|
||||
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({});
|
@ -0,0 +1,2 @@
|
||||
export * from "./collection-dialog.component";
|
||||
export * from "./collection-dialog.module";
|
2
apps/web/src/app/organizations/shared/index.ts
Normal file
2
apps/web/src/app/organizations/shared/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./shared-organization.module";
|
||||
export * from "./components/collection-dialog";
|
@ -0,0 +1,9 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { CollectionDialogModule } from "./components/collection-dialog";
|
||||
|
||||
@NgModule({
|
||||
imports: [CollectionDialogModule],
|
||||
exports: [CollectionDialogModule],
|
||||
})
|
||||
export class SharedOrganizationModule {}
|
@ -123,7 +123,7 @@ import { FolderAddEditComponent } from "../vault/folder-add-edit.component";
|
||||
import { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module";
|
||||
import { ShareComponent } from "../vault/share.component";
|
||||
|
||||
import { SharedModule } from ".";
|
||||
import { SharedModule } from "./shared.module";
|
||||
|
||||
// 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.
|
||||
|
@ -10,7 +10,6 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
AvatarModule,
|
||||
BadgeListModule,
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
@ -23,6 +22,7 @@ import {
|
||||
MultiSelectModule,
|
||||
TableModule,
|
||||
TabsModule,
|
||||
BadgeListModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
// Register the locales for the application
|
||||
@ -39,6 +39,7 @@ import "./locales";
|
||||
imports: [
|
||||
CommonModule,
|
||||
DragDropModule,
|
||||
DialogModule,
|
||||
FormsModule,
|
||||
InfiniteScrollModule,
|
||||
JslibModule,
|
||||
@ -66,6 +67,7 @@ import "./locales";
|
||||
CommonModule,
|
||||
AsyncActionsModule,
|
||||
DragDropModule,
|
||||
DialogModule,
|
||||
FormsModule,
|
||||
InfiniteScrollModule,
|
||||
JslibModule,
|
||||
@ -80,6 +82,7 @@ import "./locales";
|
||||
MultiSelectModule,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
IconButtonModule,
|
||||
TabsModule,
|
||||
TableModule,
|
||||
AvatarModule,
|
||||
|
@ -2396,10 +2396,13 @@
|
||||
"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": {
|
||||
"message": "External id"
|
||||
"message": "External ID"
|
||||
},
|
||||
"externalIdDesc": {
|
||||
"message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector or API."
|
||||
"message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector and API."
|
||||
},
|
||||
"nestCollectionUnder": {
|
||||
"message": "Nest collection under"
|
||||
},
|
||||
"accessControl": {
|
||||
"message": "Access control"
|
||||
@ -2422,6 +2425,12 @@
|
||||
"editCollection": {
|
||||
"message": "Edit collection"
|
||||
},
|
||||
"collectionInfo": {
|
||||
"message": "Collection info"
|
||||
},
|
||||
"access": {
|
||||
"message": "Access"
|
||||
},
|
||||
"deleteCollectionConfirmation": {
|
||||
"message": "Are you sure you want to delete this collection?"
|
||||
},
|
||||
@ -5454,6 +5463,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputForbiddenCharacters": {
|
||||
"message": "The following characters are not allowed: $CHARACTERS$",
|
||||
"placeholders": {
|
||||
"characters": {
|
||||
"content": "$1",
|
||||
"example": "@, #, $, %"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputMaxLength": {
|
||||
"message": "Input must not exceed $COUNT$ characters in length.",
|
||||
"placeholders": {
|
||||
@ -5547,9 +5565,6 @@
|
||||
"accessAllCollectionsHelp": {
|
||||
"message": "If checked, this will replace all other collection permissions."
|
||||
},
|
||||
"selectMembers": {
|
||||
"message": "Select members"
|
||||
},
|
||||
"selectCollections": {
|
||||
"message": "Select collections"
|
||||
},
|
||||
@ -5588,5 +5603,26 @@
|
||||
},
|
||||
"memberAccessAll": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
InternalFolderService,
|
||||
} from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
|
||||
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 { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/abstractions/keyConnector.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
@ -88,6 +89,7 @@ import { FileUploadService } from "@bitwarden/common/services/fileUpload.service
|
||||
import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service";
|
||||
import { FolderService } from "@bitwarden/common/services/folder/folder.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 { LoginService } from "@bitwarden/common/services/login.service";
|
||||
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
|
||||
@ -580,6 +582,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
||||
useClass: ValidationService,
|
||||
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction],
|
||||
},
|
||||
{
|
||||
provide: GroupServiceAbstraction,
|
||||
useClass: GroupService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
},
|
||||
{
|
||||
provide: LoginServiceAbstraction,
|
||||
useClass: LoginService,
|
||||
|
@ -92,7 +92,7 @@ import { BillingPaymentResponse } from "../models/response/billing-payment.respo
|
||||
import { BreachAccountResponse } from "../models/response/breach-account.response";
|
||||
import { CipherResponse } from "../models/response/cipher.response";
|
||||
import {
|
||||
CollectionGroupDetailsResponse,
|
||||
CollectionAccessDetailsResponse,
|
||||
CollectionResponse,
|
||||
} from "../models/response/collection.response";
|
||||
import { DeviceVerificationResponse } from "../models/response/device-verification.response";
|
||||
@ -314,7 +314,7 @@ export abstract class ApiService {
|
||||
getCollectionDetails: (
|
||||
organizationId: string,
|
||||
id: string
|
||||
) => Promise<CollectionGroupDetailsResponse>;
|
||||
) => Promise<CollectionAccessDetailsResponse>;
|
||||
getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
|
||||
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
|
||||
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>;
|
||||
|
@ -0,0 +1,9 @@
|
||||
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[]>;
|
||||
}
|
2
libs/common/src/abstractions/group/index.ts
Normal file
2
libs/common/src/abstractions/group/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./group.service.abstraction";
|
||||
export * from "./responses/group-response";
|
@ -0,0 +1,31 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
@ -440,6 +440,10 @@ export class Utils {
|
||||
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) {
|
||||
return (
|
||||
win.navigator.userAgent.match(/iPhone/i) != null ||
|
||||
|
@ -6,6 +6,7 @@ export class CollectionRequest {
|
||||
name: string;
|
||||
externalId: string;
|
||||
groups: SelectionReadOnlyRequest[] = [];
|
||||
users: SelectionReadOnlyRequest[] = [];
|
||||
|
||||
constructor(collection?: Collection) {
|
||||
if (collection == null) {
|
||||
|
@ -25,8 +25,9 @@ export class CollectionDetailsResponse extends CollectionResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export class CollectionGroupDetailsResponse extends CollectionResponse {
|
||||
export class CollectionAccessDetailsResponse extends CollectionResponse {
|
||||
groups: SelectionReadOnlyResponse[] = [];
|
||||
users: SelectionReadOnlyResponse[] = [];
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@ -34,5 +35,10 @@ export class CollectionGroupDetailsResponse extends CollectionResponse {
|
||||
if (groups != null) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Collection } from "../domain/collection";
|
||||
import { ITreeNodeObject } from "../domain/tree-node";
|
||||
import { CollectionGroupDetailsResponse } from "../response/collection.response";
|
||||
import { CollectionResponse } from "../response/collection.response";
|
||||
|
||||
import { View } from "./view";
|
||||
|
||||
@ -12,7 +12,7 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
readOnly: boolean = null;
|
||||
hidePasswords: boolean = null;
|
||||
|
||||
constructor(c?: Collection | CollectionGroupDetailsResponse) {
|
||||
constructor(c?: Collection | CollectionResponse) {
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
17
libs/common/src/models/view/group-view.ts
Normal file
17
libs/common/src/models/view/group-view.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
}
|
@ -100,7 +100,7 @@ import { BillingPaymentResponse } from "../models/response/billing-payment.respo
|
||||
import { BreachAccountResponse } from "../models/response/breach-account.response";
|
||||
import { CipherResponse } from "../models/response/cipher.response";
|
||||
import {
|
||||
CollectionGroupDetailsResponse,
|
||||
CollectionAccessDetailsResponse,
|
||||
CollectionResponse,
|
||||
} from "../models/response/collection.response";
|
||||
import { DeviceVerificationResponse } from "../models/response/device-verification.response";
|
||||
@ -810,7 +810,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
async getCollectionDetails(
|
||||
organizationId: string,
|
||||
id: string
|
||||
): Promise<CollectionGroupDetailsResponse> {
|
||||
): Promise<CollectionAccessDetailsResponse> {
|
||||
const r = await this.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/collections/" + id + "/details",
|
||||
@ -818,7 +818,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
true,
|
||||
true
|
||||
);
|
||||
return new CollectionGroupDetailsResponse(r);
|
||||
return new CollectionAccessDetailsResponse(r);
|
||||
}
|
||||
|
||||
async getUserCollections(): Promise<ListResponse<CollectionResponse>> {
|
||||
|
65
libs/common/src/services/group/group.service.ts
Normal file
65
libs/common/src/services/group/group.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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)) ?? [];
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
export class OrganizationGroupBulkRequest {
|
||||
ids: string[];
|
||||
|
||||
constructor(ids: string[]) {
|
||||
this.ids = ids == null ? [] : ids;
|
||||
}
|
||||
}
|
@ -3,10 +3,9 @@ import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
|
||||
import { BitActionDirective } from "./bit-action.directive";
|
||||
import { BitSubmitDirective } from "./bit-submit.directive";
|
||||
|
||||
import { BitActionDirective } from ".";
|
||||
|
||||
/**
|
||||
* This directive has two purposes:
|
||||
*
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Component, Input, OnChanges } from "@angular/core";
|
||||
|
||||
import { BadgeTypes } from "../badge";
|
||||
import { BadgeType } from "../badge";
|
||||
|
||||
@Component({
|
||||
selector: "bit-badge-list",
|
||||
@ -12,7 +12,7 @@ export class BadgeListComponent implements OnChanges {
|
||||
protected filteredItems: string[] = [];
|
||||
protected isFiltered = false;
|
||||
|
||||
@Input() badgeType: BadgeTypes = "primary";
|
||||
@Input() badgeType: BadgeType = "primary";
|
||||
@Input() items: string[] = [];
|
||||
|
||||
@Input()
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
export type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
export type BadgeType = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
|
||||
const styles: Record<BadgeTypes, string[]> = {
|
||||
const styles: Record<BadgeType, string[]> = {
|
||||
primary: ["tw-bg-primary-500"],
|
||||
secondary: ["tw-bg-text-muted"],
|
||||
success: ["tw-bg-success-500"],
|
||||
@ -11,7 +11,7 @@ const styles: Record<BadgeTypes, string[]> = {
|
||||
info: ["tw-bg-info-500"],
|
||||
};
|
||||
|
||||
const hoverStyles: Record<BadgeTypes, string[]> = {
|
||||
const hoverStyles: Record<BadgeType, string[]> = {
|
||||
primary: ["hover:tw-bg-primary-700"],
|
||||
secondary: ["hover:tw-bg-secondary-700"],
|
||||
success: ["hover:tw-bg-success-700"],
|
||||
@ -47,7 +47,7 @@ export class BadgeDirective {
|
||||
.concat(this.hasHoverEffects ? hoverStyles[this.badgeType] : []);
|
||||
}
|
||||
|
||||
@Input() badgeType: BadgeTypes = "primary";
|
||||
@Input() badgeType: BadgeType = "primary";
|
||||
|
||||
private hasHoverEffects = false;
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { BadgeDirective } from "./badge.directive";
|
||||
import { BadgeDirective, BadgeType } from "./badge.directive";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Badge",
|
||||
@ -15,6 +15,12 @@ export default {
|
||||
args: {
|
||||
badgeType: "primary",
|
||||
},
|
||||
argTypes: {
|
||||
badgeType: {
|
||||
options: ["primary", "secondary", "success", "danger", "warning", "info"] as BadgeType[],
|
||||
control: { type: "inline-radio" },
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
|
@ -1,2 +1,2 @@
|
||||
export { BadgeDirective, BadgeTypes } from "./badge.directive";
|
||||
export { BadgeDirective, BadgeType } from "./badge.directive";
|
||||
export * from "./badge.module";
|
||||
|
@ -4,7 +4,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { CalloutComponent } from ".";
|
||||
import { CalloutComponent } from "./callout.component";
|
||||
|
||||
describe("Callout", () => {
|
||||
let component: CalloutComponent;
|
||||
|
56
libs/components/src/form-field/bit-validators.stories.ts
Normal file
56
libs/components/src/form-field/bit-validators.stories.ts
Normal file
@ -0,0 +1,56 @@
|
||||
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,
|
||||
});
|
@ -0,0 +1,45 @@
|
||||
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);
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
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;
|
||||
};
|
||||
}
|
1
libs/components/src/form-field/bit-validators/index.ts
Normal file
1
libs/components/src/form-field/bit-validators/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { forbiddenCharacters } from "./forbidden-characters.validator";
|
@ -30,6 +30,8 @@ export class BitErrorComponent {
|
||||
return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength);
|
||||
case "maxlength":
|
||||
return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength);
|
||||
case "forbiddenCharacters":
|
||||
return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", "));
|
||||
default:
|
||||
// Attempt to show a custom error message.
|
||||
if (this.error[1]?.message) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from "./form-field.module";
|
||||
export * from "./form-field.component";
|
||||
export * from "./form-field-control";
|
||||
export * as BitValidators from "./bit-validators";
|
||||
|
@ -1 +1,2 @@
|
||||
export * from "./multi-select.module";
|
||||
export * from "./models/select-item-view";
|
||||
|
Loading…
Reference in New Issue
Block a user