invite/edit org users

This commit is contained in:
Kyle Spearrin 2018-07-10 14:46:13 -04:00
parent a8618f1c79
commit b428660f92
10 changed files with 373 additions and 9 deletions

2
jslib

@ -1 +1 @@
Subproject commit bded5eb625e3eb8f7577ad3da54d5c3b7e543eb0
Subproject commit a6a0673af8eb11ebe1afc7324751c6ab0a6e338b

View File

@ -42,6 +42,7 @@ import { GroupAddEditComponent as OrgGroupAddEditComponent } from './organizatio
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';
import { UserAddEditComponent as OrgUserAddEditComponent } from './organizations/manage/user-add-edit.component';
import { ExportComponent as OrgExportComponent } from './organizations/tools/export.component';
import { ImportComponent as OrgImportComponent } from './organizations/tools/import.component';
@ -187,6 +188,7 @@ import { SearchPipe } from 'jslib/angular/pipes/search.pipe';
OrgManageComponent,
OrgPeopleComponent,
OrgToolsComponent,
OrgUserAddEditComponent,
OrganizationsComponent,
OrganizationLayoutComponent,
OrgVaultComponent,
@ -237,6 +239,7 @@ import { SearchPipe } from 'jslib/angular/pipes/search.pipe';
OrgCollectionsComponent,
OrgEntityUsersComponent,
OrgGroupAddEditComponent,
OrgUserAddEditComponent,
PasswordGeneratorHistoryComponent,
PurgeVaultComponent,
ShareComponent,

View File

@ -21,7 +21,7 @@
<small class="form-text text-muted">{{'externalIdGroupDesc' | i18n}}</small>
</div>
<h3 class="mt-4 d-flex">
<div class="mb-1">
<div class="mb-2">
{{'accessControl' | i18n}}
</div>
<div class="ml-auto" *ngIf="access === 'selected' && collections && collections.length">
@ -33,7 +33,7 @@
</button>
</div>
</h3>
<div class="form-group">
<div class="form-group" [ngClass]="{'mb-0': access !== 'selected'}">
<div class="form-check">
<input class="form-check-input" type="radio" name="access" id="accessAll" value="all" [(ngModel)]="access">
<label class="form-check-label" for="accessAll">

View File

@ -36,7 +36,7 @@ export class GroupAddEditComponent implements OnInit {
title: string;
name: string;
externalId: string;
access: 'all' | 'selected' = 'all';
access: 'all' | 'selected' = 'selected';
collections: CollectionView[] = [];
formPromise: Promise<any>;
deletePromise: Promise<any>;

View File

@ -69,3 +69,5 @@
</tbody>
</table>
</ng-container>
<ng-template #addEdit></ng-template>
<ng-template #groupsTemplate></ng-template>

View File

@ -1,11 +1,18 @@
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 { OrganizationUserUserDetailsResponse } from 'jslib/models/response/organizationUserResponse';
@ -14,11 +21,17 @@ import { OrganizationUserType } from 'jslib/enums/organizationUserType';
import { Utils } from 'jslib/misc/utils';
import { ModalComponent } from '../../modal.component';
import { UserAddEditComponent } from './user-add-edit.component';
@Component({
selector: 'app-org-people',
templateUrl: 'people.component.html',
})
export class PeopleComponent implements OnInit {
@ViewChild('addEdit', { read: ViewContainerRef }) addEditModalRef: ViewContainerRef;
@ViewChild('groupsTemplate', { read: ViewContainerRef }) groupsModalRef: ViewContainerRef;
loading = true;
organizationId: string;
users: OrganizationUserUserDetailsResponse[];
@ -26,8 +39,12 @@ export class PeopleComponent implements OnInit {
organizationUserType = OrganizationUserType;
organizationUserStatusType = OrganizationUserStatusType;
private modal: ModalComponent = null;
constructor(private apiService: ApiService, private route: ActivatedRoute,
private i18nService: I18nService) { }
private i18nService: I18nService, private componentFactoryResolver: ComponentFactoryResolver,
private platformUtilsService: PlatformUtilsService, private analytics: Angulartics2,
private toasterService: ToasterService) { }
async ngOnInit() {
this.route.parent.parent.params.subscribe(async (params) => {
@ -45,14 +62,56 @@ export class PeopleComponent implements OnInit {
}
edit(user: OrganizationUserUserDetailsResponse) {
//
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<UserAddEditComponent>(
UserAddEditComponent, this.addEditModalRef);
childComponent.name = user != null ? user.name || user.email : null;
childComponent.organizationId = this.organizationId;
childComponent.organizationUserId = user != null ? user.id : null;
childComponent.onSavedUser.subscribe(() => {
this.modal.close();
this.load();
});
childComponent.onDeletedUser.subscribe(() => {
this.modal.close();
this.removeUser(user);
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
}
invite() {
//
this.edit(null);
}
async remove(user: OrganizationUserUserDetailsResponse) {
//
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('removeUserConfirmation'), user.name || user.email,
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return false;
}
try {
await this.apiService.deleteOrganizationUser(this.organizationId, user.id);
this.analytics.eventTrack.next({ action: 'Deleted User' });
this.toasterService.popAsync('success', null, this.i18nService.t('removedUserId', user.name || user.email));
this.removeUser(user);
} catch { }
}
private removeUser(user: OrganizationUserUserDetailsResponse) {
const index = this.users.indexOf(user);
if (index > -1) {
this.users.splice(index, 1);
}
}
}

View File

@ -0,0 +1,118 @@
<div class="modal fade">
<div class="modal-dialog" [ngClass]="{'modal-lg': !editMode}">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h2 class="modal-title">
{{title}}
<small class="text-muted" *ngIf="name">{{name}}</small>
</h2>
<button type="button" class="close" data-dismiss="modal" attr.aria-label="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="loading">
<i class="fa fa-spinner fa-spin text-muted"></i>
</div>
<div class="modal-body" *ngIf="!loading">
<ng-container *ngIf="!editMode">
<p>{{'inviteUserDesc' | i18n}}</p>
<div class="form-group mb-4">
<label for="emails">{{'email' | i18n}}</label>
<input id="emails" class="form-control" type="text" name="Emails" [(ngModel)]="emails">
<small class="text-muted">{{'inviteMultipleEmailDesc' | i18n : '20'}}</small>
</div>
</ng-container>
<h3>{{'userType' | i18n}}</h3>
<div class="form-check mt-2 form-check-block">
<input class="form-check-input" type="radio" name="userType" id="userTypeUser" [value]="organizationUserType.User" [(ngModel)]="type">
<label class="form-check-label" for="userTypeUser">
{{'user' | i18n}}
<small>{{'userDesc' | i18n}}</small>
</label>
</div>
<div class="form-check mt-2 form-check-block">
<input class="form-check-input" type="radio" name="userType" id="userTypeAdmin" [value]="organizationUserType.Admin" [(ngModel)]="type">
<label class="form-check-label" for="userTypeAdmin">
{{'admin' | i18n}}
<small>{{'adminDesc' | i18n}}</small>
</label>
</div>
<div class="form-check mt-2 form-check-block">
<input class="form-check-input" type="radio" name="userType" id="userTypeOwner" [value]="organizationUserType.Owner" [(ngModel)]="type">
<label class="form-check-label" for="userTypeOwner">
{{'owner' | i18n}}
<small>{{'ownerDesc' | i18n}}</small>
</label>
</div>
<h3 class="mt-4 d-flex">
<div class="mb-2">
{{'accessControl' | i18n}}
</div>
<div class="ml-auto" *ngIf="access === 'selected' && collections && collections.length">
<button type="button" appBlurClick (click)="selectAll(true)" class="btn btn-link btn-sm py-0">
{{'selectAll' | i18n}}
</button>
<button type="button" appBlurClick (click)="selectAll(false)" class="btn btn-link btn-sm py-0">
{{'unselectAll' | i18n}}
</button>
</div>
</h3>
<div class="form-group" [ngClass]="{'mb-0': access !== 'selected'}">
<div class="form-check">
<input class="form-check-input" type="radio" name="access" id="accessAll" value="all" [(ngModel)]="access">
<label class="form-check-label" for="accessAll">
{{'userAccessAllItems' | i18n}}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="access" id="accessSelected" value="selected" [(ngModel)]="access">
<label class="form-check-label" for="accessSelected">
{{'userAccessSelectedCollections' | i18n}}
</label>
</div>
</div>
<ng-container *ngIf="access === 'selected'">
<div *ngIf="!collections || !collections.length">
{{'noCollectionsInList' | i18n}}
</div>
<table class="table table-hover table-list mb-0" *ngIf="collections && collections.length">
<thead>
<tr>
<th>&nbsp;</th>
<th>{{'name' | i18n}}</th>
<th width="100" class="text-center">{{'readOnly' | i18n}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of collections; let i = index">
<td class="table-list-checkbox" (click)="check(c)">
<input type="checkbox" [(ngModel)]="c.checked" name="Collection[{{i}}].Checked">
</td>
<td (click)="check(c)">
<span appStopProp>{{c.name}}</span>
</td>
<td class="text-center">
<input type="checkbox" [(ngModel)]="c.readOnly" name="Collection[{{i}}].ReadOnly" [disabled]="!c.checked">
</td>
</tr>
</tbody>
</table>
</ng-container>
</div>
<div class="modal-footer">
<button appBlurClick type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin"></i>
<span>{{'save' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{'cancel' | i18n}}</button>
<div class="ml-auto">
<button #deleteBtn appBlurClick type="button" (click)="delete()" class="btn btn-outline-danger" title="{{'delete' | i18n}}"
*ngIf="editMode" [disabled]="deleteBtn.loading" [appApiAction]="deletePromise">
<i class="fa fa-trash-o fa-lg fa-fw" [hidden]="deleteBtn.loading"></i>
<i class="fa fa-spinner fa-spin fa-lg fa-fw" [hidden]="!deleteBtn.loading"></i>
</button>
</div>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,150 @@
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 { OrganizationUserInviteRequest } from 'jslib/models/request/organizationUserInviteRequest';
import { OrganizationUserUpdateRequest } from 'jslib/models/request/organizationUserUpdateRequest';
import { SelectionReadOnlyRequest } from 'jslib/models/request/selectionReadOnlyRequest';
import { CollectionDetailsResponse } from 'jslib/models/response/collectionResponse';
import { CollectionView } from 'jslib/models/view/collectionView';
import { OrganizationUserType } from 'jslib/enums/organizationUserType';
@Component({
selector: 'app-user-add-edit',
templateUrl: 'user-add-edit.component.html',
})
export class UserAddEditComponent implements OnInit {
@Input() name: string;
@Input() organizationUserId: string;
@Input() organizationId: string;
@Output() onSavedUser = new EventEmitter();
@Output() onDeletedUser = new EventEmitter();
loading = true;
editMode: boolean = false;
title: string;
emails: string;
type: OrganizationUserType = OrganizationUserType.User;
access: 'all' | 'selected' = 'selected';
collections: CollectionView[] = [];
formPromise: Promise<any>;
deletePromise: Promise<any>;
organizationUserType = OrganizationUserType;
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.organizationUserId != null;
await this.loadCollections();
if (this.editMode) {
this.editMode = true;
this.title = this.i18nService.t('editUser');
try {
const user = await this.apiService.getOrganizationUser(this.organizationId, this.organizationUserId);
this.access = user.accessAll ? 'all' : 'selected';
this.type = user.type;
if (user.collections != null && this.collections != null) {
user.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('inviteUser');
}
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() {
let collections: SelectionReadOnlyRequest[] = null;
if (this.access !== 'all') {
collections = this.collections.filter((c) => (c as any).checked)
.map((c) => new SelectionReadOnlyRequest(c.id, !!c.readOnly));
}
try {
if (this.editMode) {
const request = new OrganizationUserUpdateRequest();
request.accessAll = this.access === 'all';
request.type = this.type;
request.collections = collections;
this.formPromise = this.apiService.putOrganizationUser(this.organizationId, this.organizationUserId,
request);
} else {
const request = new OrganizationUserInviteRequest();
request.emails = this.emails.trim().split(/\s*,\s*/);
request.accessAll = this.access === 'all';
request.type = this.type;
request.collections = collections;
this.formPromise = this.apiService.postOrganizationUserInvite(this.organizationId, request);
}
await this.formPromise;
this.analytics.eventTrack.next({ action: this.editMode ? 'Edited User' : 'Invited User' });
this.toasterService.popAsync('success', null,
this.i18nService.t(this.editMode ? 'editedUserId' : 'invitedUsers', this.name));
this.onSavedUser.emit();
} catch { }
}
async delete() {
if (!this.editMode) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('removeUserConfirmation'), this.name,
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return false;
}
try {
this.deletePromise = this.apiService.deleteOrganizationUser(this.organizationId, this.organizationUserId);
await this.deletePromise;
this.analytics.eventTrack.next({ action: 'Deleted User' });
this.toasterService.popAsync('success', null, this.i18nService.t('removedUserId', this.name));
this.onDeletedUser.emit();
} catch { }
}
}

View File

@ -1754,9 +1754,24 @@
"deleteCollectionConfirmation": {
"message": "Are you sure you want to delete this collection?"
},
"editUser": {
"message": "Edit User"
},
"inviteUser": {
"message": "Invite User"
},
"inviteUserDesc": {
"message": "Invite a new user to your organization by entering their Bitwarden account email address below. If they do not have a Bitwarden account already, they will be prompted to create a new account."
},
"inviteMultipleEmailDesc": {
"message": "You can invite up to $COUNT$ users at a time by comma separating a list of email addresses.",
"placeholders": {
"count": {
"content": "$1",
"example": "20"
}
}
},
"userAccessAllItems": {
"message": "This user can access and modify all items."
},
@ -1778,12 +1793,21 @@
"owner": {
"message": "Owner"
},
"ownerDesc": {
"message": "The highest access user that can manage all aspects of your organization."
},
"admin": {
"message": "Admin"
},
"adminDesc": {
"message": " Admins can access and manage all items, collections and users in your organization."
},
"user": {
"message": "User"
},
"userDesc": {
"message": "A regular user with access to your organization's collections."
},
"all": {
"message": "All"
},
@ -2018,7 +2042,13 @@
"userAccess": {
"message": "User Access"
},
"userType": {
"message": "User Type"
},
"groupAccess": {
"message": "Group Access"
},
"invitedUsers": {
"message": "Invited user(s)."
}
}

View File

@ -632,7 +632,9 @@ app-user-billing {
}
.form-check-block + .form-check-block {
@extend .mt-3;
&:not(.mt-2) {
@extend .mt-3;
}
}
.org-nav {