diff --git a/jslib b/jslib index 0a46513e382..89e71d7c16d 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit 0a46513e38297c49aebbbfb71e73e20aaa752780 +Subproject commit 89e71d7c16d95fd7cc9634d46e0db1d77e6fcae6 diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ac4d2b35064..2c8518040d9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -34,6 +34,7 @@ import { TwoFactorComponent } from './accounts/two-factor.component'; import { CollectionsComponent as OrgManageCollectionsComponent } from './organizations/manage/collections.component'; import { EventsComponent as OrgEventsComponent } from './organizations/manage/events.component'; +import { GroupAddEditComponent as OrgGroupAddEditComponent } from './organizations/manage/group-add-edit.component'; import { GroupsComponent as OrgGroupsComponent } from './organizations/manage/groups.component'; import { ManageComponent as OrgManageComponent } from './organizations/manage/manage.component'; import { PeopleComponent as OrgPeopleComponent } from './organizations/manage/people.component'; @@ -173,6 +174,7 @@ import { SearchPipe } from 'jslib/angular/pipes/search.pipe'; OrgEventsComponent, OrgExportComponent, OrgImportComponent, + OrgGroupAddEditComponent, OrgGroupingsComponent, OrgGroupsComponent, OrgManageCollectionsComponent, @@ -226,6 +228,7 @@ import { SearchPipe } from 'jslib/angular/pipes/search.pipe'; OrgAddEditComponent, OrgAttachmentsComponent, OrgCollectionsComponent, + OrgGroupAddEditComponent, PasswordGeneratorHistoryComponent, PurgeVaultComponent, ShareComponent, diff --git a/src/app/organizations/manage/group-add-edit.component.html b/src/app/organizations/manage/group-add-edit.component.html new file mode 100644 index 00000000000..b613947b48f --- /dev/null +++ b/src/app/organizations/manage/group-add-edit.component.html @@ -0,0 +1,90 @@ + diff --git a/src/app/organizations/manage/group-add-edit.component.ts b/src/app/organizations/manage/group-add-edit.component.ts new file mode 100644 index 00000000000..809c5efc96d --- /dev/null +++ b/src/app/organizations/manage/group-add-edit.component.ts @@ -0,0 +1,141 @@ +import { + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; + +import { ToasterService } from 'angular2-toaster'; +import { Angulartics2 } from 'angulartics2'; + +import { ApiService } from 'jslib/abstractions/api.service'; +import { CollectionService } from 'jslib/abstractions/collection.service'; +import { I18nService } from 'jslib/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; + +import { CollectionData } from 'jslib/models/data/collectionData'; +import { Collection } from 'jslib/models/domain/collection'; +import { GroupRequest } from 'jslib/models/request/groupRequest'; +import { SelectionReadOnlyRequest } from 'jslib/models/request/selectionReadOnlyRequest'; +import { CollectionDetailsResponse } from 'jslib/models/response/collectionResponse'; +import { CollectionView } from 'jslib/models/view/collectionView'; + +@Component({ + selector: 'app-group-add-edit', + templateUrl: 'group-add-edit.component.html', +}) +export class GroupAddEditComponent implements OnInit { + @Input() groupId: string; + @Input() organizationId: string; + @Output() onSavedGroup = new EventEmitter(); + @Output() onDeletedGroup = new EventEmitter(); + + loading = true; + editMode: boolean = false; + title: string; + name: string; + externalId: string; + access: 'all' | 'selected' = 'all'; + collections: CollectionView[] = []; + formPromise: Promise; + deletePromise: Promise; + + constructor(private apiService: ApiService, private i18nService: I18nService, + private analytics: Angulartics2, private toasterService: ToasterService, + private collectionService: CollectionService, private platformUtilsService: PlatformUtilsService) { } + + async ngOnInit() { + this.editMode = this.loading = this.groupId != null; + await this.loadCollections(); + + if (this.editMode) { + this.editMode = true; + this.title = this.i18nService.t('editGroup'); + try { + const group = await this.apiService.getGroupDetails(this.organizationId, this.groupId); + this.access = group.accessAll ? 'all' : 'selected'; + this.name = group.name; + this.externalId = group.externalId; + if (group.collections != null && this.collections != null) { + group.collections.forEach((s) => { + const collection = this.collections.filter((c) => c.id === s.id); + if (collection != null && collection.length > 0) { + (collection[0] as any).checked = true; + collection[0].readOnly = s.readOnly; + } + }); + } + } catch { } + } else { + this.title = this.i18nService.t('addGroup'); + } + + this.loading = false; + } + + async loadCollections() { + const response = await this.apiService.getCollections(this.organizationId); + const collections = response.data.map((r) => + new Collection(new CollectionData(r as CollectionDetailsResponse))); + this.collections = await this.collectionService.decryptMany(collections); + } + + check(c: CollectionView, select?: boolean) { + (c as any).checked = select == null ? !(c as any).checked : select; + if (!(c as any).checked) { + c.readOnly = false; + } + } + + selectAll(select: boolean) { + this.collections.forEach((c) => this.check(c, select)); + } + + async submit() { + const request = new GroupRequest(); + request.name = this.name; + request.externalId = this.externalId; + request.accessAll = this.access === 'all'; + if (!request.accessAll) { + request.collections = this.collections.filter((c) => (c as any).checked) + .map((c) => new SelectionReadOnlyRequest(c.id, !!c.readOnly)); + } + + try { + if (this.editMode) { + this.formPromise = this.apiService.putGroup(this.organizationId, this.groupId, request); + } else { + this.formPromise = this.apiService.postGroup(this.organizationId, request); + } + await this.formPromise; + this.analytics.eventTrack.next({ action: this.editMode ? 'Edited Group' : 'Created Group' }); + this.toasterService.popAsync('success', null, + this.i18nService.t(this.editMode ? 'editedThing' : 'createdThing', + this.i18nService.t('group').toLocaleLowerCase(), this.name)); + this.onSavedGroup.emit(); + } catch { } + } + + async delete() { + if (!this.editMode) { + return; + } + + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t('deleteGroupConfirmation'), this.name, + this.i18nService.t('yes'), this.i18nService.t('no'), 'warning'); + if (!confirmed) { + return false; + } + + try { + this.deletePromise = this.apiService.deleteGroup(this.organizationId, this.groupId); + await this.deletePromise; + this.analytics.eventTrack.next({ action: 'Deleted Group' }); + this.toasterService.popAsync('success', null, + this.i18nService.t('deletedThing', this.i18nService.t('group').toLocaleLowerCase(), this.name)); + this.onDeletedGroup.emit(); + } catch { } + } +} diff --git a/src/app/organizations/manage/groups.component.html b/src/app/organizations/manage/groups.component.html index a7ad009d1e0..c495b4d485f 100644 --- a/src/app/organizations/manage/groups.component.html +++ b/src/app/organizations/manage/groups.component.html @@ -41,3 +41,4 @@ + diff --git a/src/app/organizations/manage/groups.component.ts b/src/app/organizations/manage/groups.component.ts index 82b95fc2923..3baf8170851 100644 --- a/src/app/organizations/manage/groups.component.ts +++ b/src/app/organizations/manage/groups.component.ts @@ -1,28 +1,44 @@ import { Component, + ComponentFactoryResolver, OnInit, + ViewChild, + ViewContainerRef, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { ToasterService } from 'angular2-toaster'; +import { Angulartics2 } from 'angulartics2'; + import { ApiService } from 'jslib/abstractions/api.service'; import { I18nService } from 'jslib/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; import { GroupResponse } from 'jslib/models/response/groupResponse'; import { Utils } from 'jslib/misc/utils'; +import { ModalComponent } from '../../modal.component'; +import { GroupAddEditComponent } from './group-add-edit.component'; + @Component({ selector: 'app-org-groups', templateUrl: 'groups.component.html', }) export class GroupsComponent implements OnInit { + @ViewChild('addEdit', { read: ViewContainerRef }) addEditModalRef: ViewContainerRef; + loading = true; organizationId: string; groups: GroupResponse[]; searchText: string; + private modal: ModalComponent = null; + constructor(private apiService: ApiService, private route: ActivatedRoute, - private i18nService: I18nService) { } + private i18nService: I18nService, private componentFactoryResolver: ComponentFactoryResolver, + private analytics: Angulartics2, private toasterService: ToasterService, + private platformUtilsService: PlatformUtilsService) { } async ngOnInit() { this.route.parent.parent.params.subscribe(async (params) => { @@ -38,4 +54,51 @@ export class GroupsComponent implements OnInit { this.groups = groups; this.loading = false; } + + edit(group: GroupResponse) { + if (this.modal != null) { + this.modal.close(); + } + + const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent); + this.modal = this.addEditModalRef.createComponent(factory).instance; + const childComponent = this.modal.show( + GroupAddEditComponent, this.addEditModalRef); + + childComponent.organizationId = this.organizationId; + childComponent.groupId = group != null ? group.id : null; + childComponent.onSavedGroup.subscribe(() => { + this.modal.close(); + this.load(); + }); + childComponent.onDeletedGroup.subscribe(() => { + this.modal.close(); + this.load(); + }); + + this.modal.onClosed.subscribe(() => { + this.modal = null; + }); + } + + add() { + this.edit(null); + } + + async delete(group: GroupResponse) { + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t('deleteGroupConfirmation'), group.name, + this.i18nService.t('yes'), this.i18nService.t('no'), 'warning'); + if (!confirmed) { + return false; + } + + try { + await this.apiService.deleteGroup(this.organizationId, group.id); + this.analytics.eventTrack.next({ action: 'Deleted Group' }); + this.toasterService.popAsync('success', null, + this.i18nService.t('deletedThing', this.i18nService.t('group').toLocaleLowerCase(), group.name)); + await this.load(); + } catch { } + } } diff --git a/src/app/vault/bulk-share.component.ts b/src/app/vault/bulk-share.component.ts index 09ff609cbe2..7bc9e0d36ed 100644 --- a/src/app/vault/bulk-share.component.ts +++ b/src/app/vault/bulk-share.component.ts @@ -75,14 +75,12 @@ export class BulkShareComponent implements OnInit { this.toasterService.popAsync('success', null, this.i18nService.t('sharedItems')); } - check(c: CollectionView) { - (c as any).checked = !(c as any).checked; + check(c: CollectionView, select?: boolean) { + (c as any).checked = select == null ? !(c as any).checked : select; } - selectAll(select: false) { + selectAll(select: boolean) { const collections = select ? this.collections : this.writeableCollections; - for (const c of collections) { - (c as any).checked = select; - } + collections.forEach((c) => this.check(c, select)); } } diff --git a/src/app/vault/ciphers.component.ts b/src/app/vault/ciphers.component.ts index 236e9046e52..50afe5e4965 100644 --- a/src/app/vault/ciphers.component.ts +++ b/src/app/vault/ciphers.component.ts @@ -38,8 +38,8 @@ export class CiphersComponent extends BaseCiphersComponent { super(cipherService); } - checkCipher(c: CipherView) { - (c as any).checked = !(c as any).checked; + checkCipher(c: CipherView, select?: boolean) { + (c as any).checked = select == null ? !(c as any).checked : select; } selectAll(select: boolean) { @@ -48,7 +48,7 @@ export class CiphersComponent extends BaseCiphersComponent { } const selectCount = select && this.ciphers.length > MaxCheckedCount ? MaxCheckedCount : this.ciphers.length; for (let i = 0; i < selectCount; i++) { - (this.ciphers[i] as any).checked = select; + this.checkCipher(this.ciphers[i], select); } } diff --git a/src/app/vault/collections.component.html b/src/app/vault/collections.component.html index 41255fd1f48..e4862316f54 100644 --- a/src/app/vault/collections.component.html +++ b/src/app/vault/collections.component.html @@ -15,10 +15,10 @@

{{'collections' | i18n}}

- - diff --git a/src/app/vault/collections.component.ts b/src/app/vault/collections.component.ts index c2201af28d7..fea8e6d98bd 100644 --- a/src/app/vault/collections.component.ts +++ b/src/app/vault/collections.component.ts @@ -44,7 +44,7 @@ export class CollectionsComponent implements OnInit, OnDestroy { this.cipher = await this.cipherDomain.decrypt(); this.collections = await this.loadCollections(); - this.unselectAll(); + this.selectAll(false); if (this.collectionIds != null) { this.collections.forEach((c) => { (c as any).checked = this.collectionIds.indexOf(c.id) > -1; @@ -53,7 +53,7 @@ export class CollectionsComponent implements OnInit, OnDestroy { } ngOnDestroy() { - this.unselectAll(); + this.selectAll(false); } async submit() { @@ -67,20 +67,12 @@ export class CollectionsComponent implements OnInit, OnDestroy { this.toasterService.popAsync('success', null, this.i18nService.t('editedItem')); } - check(c: CollectionView) { - (c as any).checked = !(c as any).checked; + check(c: CollectionView, select?: boolean) { + (c as any).checked = select == null ? !(c as any).checked : select; } - selectAll() { - for (const c of this.collections) { - (c as any).checked = true; - } - } - - unselectAll() { - for (const c of this.collections) { - (c as any).checked = false; - } + selectAll(select: boolean) { + this.collections.forEach((c) => this.check(c, select)); } protected loadCipher() { diff --git a/src/app/vault/share.component.ts b/src/app/vault/share.component.ts index 5fcb459c15a..586fc044ed9 100644 --- a/src/app/vault/share.component.ts +++ b/src/app/vault/share.component.ts @@ -87,14 +87,12 @@ export class ShareComponent implements OnInit, OnDestroy { await this.formPromise; } - check(c: CollectionView) { - (c as any).checked = !(c as any).checked; + check(c: CollectionView, select?: boolean) { + (c as any).checked = select == null ? !(c as any).checked : select; } selectAll(select: false) { const collections = select ? this.collections : this.writeableCollections; - for (const c of collections) { - (c as any).checked = select; - } + collections.forEach((c) => this.check(c, select)); } } diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index 28d11f9f0b6..bfdba4c196e 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -591,6 +591,15 @@ "noItemsInList": { "message": "There are no items to list." }, + "noCollectionsInList": { + "message": "There are no collections to list." + }, + "noGroupsInList": { + "message": "There are no collections to list." + }, + "noUsersInList": { + "message": "There are no users to list." + }, "newOrganization": { "message": "New Organization" }, @@ -1703,12 +1712,48 @@ "newGroup": { "message": "New Group" }, + "addGroup": { + "message": "Add Group" + }, + "editGroup": { + "message": "Edit Group" + }, + "deleteGroupConfirmation": { + "message": "Are you sure you want to delete this group?" + }, + "externalId": { + "message": "External Id" + }, + "accessControl": { + "message": "Access Control" + }, + "groupAccessAllItems": { + "message": "This group can access and modify all items." + }, + "groupAccessSelectedCollections": { + "message": "This group can access only the selected collections." + }, + "readOnly": { + "message": "Read Only" + }, "newCollection": { "message": "New Collection" }, + "addCollection": { + "message": "Add Collection" + }, + "editCollection": { + "message": "Edit Collection" + }, "inviteUser": { "message": "Invite User" }, + "userAccessAllItems": { + "message": "This user can access and modify all items." + }, + "userAccessSelectedCollections": { + "message": "This user can access only the selected collections." + }, "search": { "message": "Search" }, @@ -1789,7 +1834,7 @@ }, "createdThing": { "message": "Created $THING$ $ID$.", - "description": "Created item abe89f32.", + "description": "Created item 'Google'.", "placeholders": { "thing": { "content": "$1", @@ -1797,13 +1842,13 @@ }, "id": { "content": "$2", - "example": "abe89f32" + "example": "Google" } } }, "editedThing": { "message": "Edited $THING$ $ID$.", - "description": "Edited item abe89f32.", + "description": "Edited item 'Google'.", "placeholders": { "thing": { "content": "$1", @@ -1811,13 +1856,13 @@ }, "id": { "content": "$2", - "example": "abe89f32" + "example": "Google" } } }, "deletedThing": { "message": "Deleted $THING$ $ID$.", - "description": "Deleted item abe89f32.", + "description": "Deleted item 'Google'.", "placeholders": { "thing": { "content": "$1", @@ -1825,12 +1870,13 @@ }, "id": { "content": "$2", - "example": "abe89f32" + "example": "Google" } } }, "sharedThing": { "message": "Shared $THING$ $ID$.", + "description": "Shared item 'Google'.", "placeholders": { "thing": { "content": "$1", @@ -1838,12 +1884,13 @@ }, "id": { "content": "$2", - "example": "abe89f32" + "example": "'Google'" } } }, "removedThing": { "message": "Removed $THING$ $ID$.", + "description": "Shared item 'Google'.", "placeholders": { "thing": { "content": "$1", @@ -1851,7 +1898,7 @@ }, "id": { "content": "$2", - "example": "abe89f32" + "example": "Google" } } }, @@ -1860,7 +1907,7 @@ "placeholders": { "id": { "content": "$1", - "example": "abe89f32" + "example": "Google" } } }, @@ -1869,7 +1916,7 @@ "placeholders": { "id": { "content": "$1", - "example": "abe89f32" + "example": "Google" } } }, @@ -1878,7 +1925,7 @@ "placeholders": { "id": { "content": "$1", - "example": "abe89f32" + "example": "Google" } } }, @@ -1887,7 +1934,7 @@ "placeholders": { "id": { "content": "$1", - "example": "abe89f32" + "example": "John Smith" } } }, @@ -1896,7 +1943,7 @@ "placeholders": { "id": { "content": "$1", - "example": "abe89f32" + "example": "John Smith" } } }, @@ -1905,7 +1952,7 @@ "placeholders": { "id": { "content": "$1", - "example": "abe89f32" + "example": "John Smith" } } }, diff --git a/src/scss/styles.scss b/src/scss/styles.scss index 4c6ff6c5538..a3ba9b2a240 100644 --- a/src/scss/styles.scss +++ b/src/scss/styles.scss @@ -265,6 +265,10 @@ label:not(.form-check-label):not(.btn) { } .table.table-list { + thead th { + border-top: none; + } + tr:first-child { td { border: none;