diff --git a/client/package-lock.json b/client/package-lock.json index 100c19c11..8af24c639 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5387,13 +5387,15 @@ "version": "1.0.0", "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5416,7 +5418,8 @@ "version": "0.0.1", "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", @@ -5583,6 +5586,7 @@ "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } diff --git a/client/src/app/core/services/data-send.service.ts b/client/src/app/core/services/data-send.service.ts index b9f3596ae..fcfbbcbc6 100644 --- a/client/src/app/core/services/data-send.service.ts +++ b/client/src/app/core/services/data-send.service.ts @@ -29,7 +29,7 @@ export class DataSendService { tap( response => { // TODO: Message, Notify, Etc - console.log('New Model added. Response :\n', response); + console.log('New Model added. Response ::\n', response); }, error => console.error('createModel has returned an Error:\n', error) ) @@ -56,7 +56,7 @@ export class DataSendService { tap( response => { // TODO: Message, Notify, Etc - console.log('Update model. Response :\n', response); + console.log('Update model. Response ::\n', response); }, error => console.error('updateModel has returned an Error:\n', error) ) diff --git a/client/src/app/shared/components/head-bar/head-bar.component.html b/client/src/app/shared/components/head-bar/head-bar.component.html index aab8a6cb8..4b787d3f9 100644 --- a/client/src/app/shared/components/head-bar/head-bar.component.html +++ b/client/src/app/shared/components/head-bar/head-bar.component.html @@ -16,8 +16,11 @@ - + + + + diff --git a/client/src/app/shared/components/head-bar/head-bar.component.ts b/client/src/app/shared/components/head-bar/head-bar.component.ts index 539f4e90f..d5189a266 100644 --- a/client/src/app/shared/components/head-bar/head-bar.component.ts +++ b/client/src/app/shared/components/head-bar/head-bar.component.ts @@ -1,4 +1,5 @@ import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +import { OperatorService } from '../../../core/services/operator.service'; /** * Reusable head bar component for Apps. @@ -86,7 +87,7 @@ export class HeadBarComponent implements OnInit { /** * Empty constructor */ - public constructor() {} + public constructor(private op: OperatorService) {} /** * empty onInit @@ -107,4 +108,22 @@ export class HeadBarComponent implements OnInit { public clickPlusButton(): void { this.plusButtonClicked.emit(true); } + + /** + * Determine if the operator has the correct permission to use a button in the menu + * @param perm + */ + public opHasPerm(perm: string): boolean { + // return false if the operator is not yet loaded + if (this.op) { + // if no permission was required, return true + if (!perm) { + return true; + } else { + return this.op.hasPerms(perm); + } + } else { + return false; + } + } } diff --git a/client/src/app/shared/models/users/group.ts b/client/src/app/shared/models/users/group.ts index a31ded7ff..14b790ab5 100644 --- a/client/src/app/shared/models/users/group.ts +++ b/client/src/app/shared/models/users/group.ts @@ -11,6 +11,10 @@ export class Group extends BaseModel { public constructor(input?: any) { super('users/group', input); + if (!input) { + // permissions are required for new groups + this.permissions = []; + } } public getTitle(): string { diff --git a/client/src/app/site/users/components/group-list/group-list.component.html b/client/src/app/site/users/components/group-list/group-list.component.html new file mode 100644 index 000000000..367d4fa9d --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.html @@ -0,0 +1,88 @@ + + + +
+ Groups +
+ + +
+ +
+
+ + + + + +
+
+ +
+
+ + + + + + + + + +
+
+ +
+ All your changes are saved immediately. +
+ + + + + + {{ app.name }} + + + +
+ + + Permissions + + {{ perm.display_name }} + + + +
+ + +
+ {{ group.name }} +
+
+ +
+ +
+
+
+
+ + + +
+
+
+
diff --git a/client/src/app/site/users/components/group-list/group-list.component.scss b/client/src/app/site/users/components/group-list/group-list.component.scss new file mode 100644 index 000000000..e42a0e912 --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.scss @@ -0,0 +1,39 @@ +table { + width: 100%; + + .mat-cell { + min-width: 80px; + } + + .mat-column-perm { + min-width: 130px; + } + + .inner-table { + width: 100%; + text-align: center; + } + + .group-head-table-cell { + cursor: pointer; + } +} + +.hint-text { + padding-top: 30px; + padding-left: 25px; + background-color: #ffffff; // put in theme later +} + +.new-group-form { + text-align: center; + padding-top: 10px; + background-color: #ffffff; // put in theme later + button { + margin-left: 10px; + } +} + +.scrollable-perm-matrix { + overflow: auto; +} diff --git a/client/src/app/site/users/components/group-list/group-list.component.spec.ts b/client/src/app/site/users/components/group-list/group-list.component.spec.ts new file mode 100644 index 000000000..23ac7194b --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupListComponent } from './group-list.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('GroupListComponent', () => { + let component: GroupListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [GroupListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GroupListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/users/components/group-list/group-list.component.ts b/client/src/app/site/users/components/group-list/group-list.component.ts new file mode 100644 index 000000000..d11867868 --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.ts @@ -0,0 +1,192 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { MatTableDataSource } from '@angular/material'; +import { FormGroup } from '@angular/forms'; + +import { GroupRepositoryService } from '../../services/group-repository.service'; +import { ViewGroup } from '../../models/view-group'; +import { Group } from '../../../../shared/models/users/group'; +import { BaseComponent } from '../../../../base.component'; + +/** + * Component for the Group-List and permission matrix + */ +@Component({ + selector: 'os-group-list', + templateUrl: './group-list.component.html', + styleUrls: ['./group-list.component.scss'] +}) +export class GroupListComponent extends BaseComponent implements OnInit { + /** + * Holds all Groups + */ + public groups: ViewGroup[]; + + /** + * The header rows that the table should show + */ + public headerRowDef: string[] = []; + + /** + * Show or hide the new groups box + */ + public newGroup = false; + + /** + * Show or hide edit Group features + */ + public editGroup = false; + + /** + * Store the group to edit + */ + public selectedGroup: ViewGroup; + + /** + * Constructor + * + * @param titleService Title Service + * @param translate Translations + * @param DS The Data Store + * @param constants Constants + */ + public constructor(titleService: Title, translate: TranslateService, public repo: GroupRepositoryService) { + super(titleService, translate); + } + + /** + * Trigger for the new Group button + */ + public newGroupButton(): void { + this.editGroup = false; + this.newGroup = !this.newGroup; + } + + /** + * Saves a newly created group. + * @param form form data given by the group + */ + public submitNewGroup(form: FormGroup): void { + if (form.value) { + this.repo.create(form.value).subscribe(response => { + if (response) { + form.reset(); + // commenting the next line would allow to create multiple groups without reopening the form + this.newGroup = false; + } + }); + } + } + + /** + * Saves an edited group. + * @param form form data given by the group + */ + public submitEditedGroup(form: FormGroup): void { + if (form.value) { + const updateData = new Group({ name: form.value.name }); + + this.repo.update(updateData, this.selectedGroup).subscribe(response => { + if (response) { + this.cancelEditing(); + } + }); + } + } + + /** + * Deletes the selected Group + */ + public deleteSelectedGroup(): void { + this.repo.delete(this.selectedGroup).subscribe(response => this.cancelEditing()); + } + + /** + * Cancel the editing + */ + public cancelEditing(): void { + this.editGroup = false; + } + + /** + * Select group in head bar + */ + public selectGroup(group: ViewGroup): void { + this.newGroup = false; + this.selectedGroup = group; + this.editGroup = true; + } + + /** + * Triggers when a permission was toggled + * @param group + * @param perm + */ + public togglePerm(viewGroup: ViewGroup, perm: string): void { + const updateData = new Group({ permissions: viewGroup.getAlteredPermissions(perm) }); + this.repo.update(updateData, viewGroup).subscribe(); + } + + /** + * Update the rowDefinition after Reloading or changes + */ + public updateRowDef(): void { + // reset the rowDef list first + this.headerRowDef = ['perm']; + this.groups.forEach(viewGroup => { + this.headerRowDef.push('' + viewGroup.name); + }); + } + + /** + * Required to detect changes in *ngFor loops + * + * @param group Corresponding group that was changed + */ + public trackGroupArray(group: ViewGroup): number { + return group.id; + } + + /** + * Converts a permission string into MatTableDataSource + * @param permissions + */ + public getTableDataSource(permissions: string[]): MatTableDataSource { + const dataSource = new MatTableDataSource(); + dataSource.data = permissions; + return dataSource; + } + + /** + * Determine if a group is protected from deletion + * @param group ViewGroup + */ + public isProtected(group: ViewGroup): boolean { + return group.id === 1 || group.id === 2; + } + + /** + * Clicking escape while in #newGroupForm should toggle newGroup. + */ + public keyDownFunction(event: KeyboardEvent): void { + if (event.keyCode === 27) { + this.newGroup = false; + } + } + + /** + * Init function. + * + * Monitor the repository for changes and update the local groups array + */ + public ngOnInit(): void { + super.setTitle('Groups'); + this.repo.getViewModelListObservable().subscribe(newViewGroups => { + if (newViewGroups) { + this.groups = newViewGroups; + this.updateRowDef(); + } + }); + } +} diff --git a/client/src/app/site/users/components/user-list/user-list.component.ts b/client/src/app/site/users/components/user-list/user-list.component.ts index 71a81e97c..b1d5b4994 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.ts +++ b/client/src/app/site/users/components/user-list/user-list.component.ts @@ -24,7 +24,8 @@ export class UserListComponent extends ListViewBaseComponent implement { text: 'Groups', icon: 'users', - action: 'toGroups' + action: 'toGroups', + perm: 'users.can_manage' }, { text: 'Import', @@ -81,7 +82,7 @@ export class UserListComponent extends ListViewBaseComponent implement * TODO: implement */ public toGroups(): void { - console.log('to Groups'); + this.router.navigate(['./groups'], { relativeTo: this.route }); } /** diff --git a/client/src/app/site/users/models/view-group.ts b/client/src/app/site/users/models/view-group.ts new file mode 100644 index 000000000..926ce23d6 --- /dev/null +++ b/client/src/app/site/users/models/view-group.ts @@ -0,0 +1,76 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Group } from '../../../shared/models/users/group'; +import { BaseModel } from '../../../shared/models/base/base-model'; + +export class ViewGroup extends BaseViewModel { + private _group: Group; + + public get group(): Group { + return this._group ? this._group : null; + } + + public get id(): number { + return this.group ? this.group.id : null; + } + + public get name(): string { + return this.group ? this.group.name : null; + } + + /** + * required for renaming purpose + */ + public set name(newName: string) { + if (this.group) { + this.group.name = newName; + } + } + + public get permissions(): string[] { + return this.group ? this.group.permissions : null; + } + + public constructor(group?: Group) { + super(); + this._group = group; + } + + /** + * Returns an array of permissions where the given perm is included + * or removed. + * + * Avoids touching the local DataStore. + * + * @param perm + */ + public getAlteredPermissions(perm: string): string[] { + // clone the array, avoids altering the local dataStore + const currentPermissions = this.permissions.slice(); + + if (this.hasPermission(perm)) { + // remove the permission from currentPermissions-List + const indexOfPerm = currentPermissions.indexOf(perm); + if (indexOfPerm !== -1) { + currentPermissions.splice(indexOfPerm, 1); + return currentPermissions; + } else { + return currentPermissions; + } + } else { + currentPermissions.push(perm); + return currentPermissions; + } + } + + public hasPermission(perm: string): boolean { + return this.permissions.includes(perm); + } + + public getTitle(): string { + return this.name; + } + + public updateValues(update: BaseModel): void { + console.log('ViewGroups wants to update Values with : ', update); + } +} diff --git a/client/src/app/site/users/services/group-repository.service.spec.ts b/client/src/app/site/users/services/group-repository.service.spec.ts new file mode 100644 index 000000000..8a7047055 --- /dev/null +++ b/client/src/app/site/users/services/group-repository.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { GroupRepositoryService } from './group-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('GroupRepositoryService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [GroupRepositoryService] + })); + + it('should be created', inject([GroupRepositoryService], (service: GroupRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/users/services/group-repository.service.ts b/client/src/app/site/users/services/group-repository.service.ts new file mode 100644 index 000000000..55b4e6093 --- /dev/null +++ b/client/src/app/site/users/services/group-repository.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { ViewGroup } from '../models/view-group'; +import { BaseRepository } from '../../base/base-repository'; +import { Group } from '../../../shared/models/users/group'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { DataSendService } from '../../../core/services/data-send.service'; +import { ConstantsService } from '../../../core/services/constants.service'; + +/** + * Set rules to define the shape of an app permission + */ +interface AppPermission { + name: string; + permissions: string[]; +} + +/** + * Repository service for Groups + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class GroupRepositoryService extends BaseRepository { + /** + * holds sorted permissions per app. + */ + public appPermissions: AppPermission[] = []; + + /** + * Constructor calls the parent constructor + * @param DS Store + * @param dataSend Sending Data + */ + public constructor(DS: DataStoreService, private dataSend: DataSendService, private constants: ConstantsService) { + super(DS, Group); + this.sortPermsPerApp(); + } + + /** + * Add an entry to appPermissions + * + * @param appId number that indicates the app + * @param perm certain permission as string + * @param appName Indicates the header in the Permission Matrix + */ + private addAppPerm(appId: number, perm: string, appName: string): void { + if (!this.appPermissions[appId]) { + this.appPermissions[appId] = { + name: appName, + permissions: [] + }; + } + this.appPermissions[appId].permissions.push(perm); + } + + /** + * read the constants, add them to an array of apps + */ + private sortPermsPerApp(): void { + this.constants.get('permissions').subscribe(perms => { + perms.forEach(perm => { + // extract the apps name + const permApp = perm.value.split('.')[0]; + switch (permApp) { + case 'core': + if (perm.value.indexOf('projector') > -1) { + this.addAppPerm(0, perm, 'Projector'); + } else { + this.addAppPerm(6, perm, 'General'); + } + break; + case 'agenda': + this.addAppPerm(1, perm, 'Agenda'); + break; + case 'motions': + this.addAppPerm(2, perm, 'Motions'); + break; + case 'assignments': + this.addAppPerm(3, perm, 'Assignments'); + break; + case 'mediafiles': + this.addAppPerm(4, perm, 'Mediafiles'); + break; + case 'users': + this.addAppPerm(5, perm, 'Users'); + break; + default: + // plugins + const displayName = `${permApp.charAt(0).toUpperCase}${permApp.slice(1)}`; + // check if the plugin exists as app + const result = this.appPermissions.findIndex(app => { + return app.name === displayName; + }); + const pluginId = result === -1 ? this.appPermissions.length : result; + this.addAppPerm(pluginId, perm, displayName); + break; + } + }); + }); + } + + /** + * creates and saves a new user + * + * @param groupData form value. Usually not yet a real user + */ + public create(groupData: Partial): Observable { + const newGroup = new Group(); + newGroup.patchValues(groupData); + return this.dataSend.createModel(newGroup); + } + + /** + * Updates the given Group with the new permission + * + * @param permission the new permission + * @param viewGroup the selected Group + */ + public update(groupData: Partial, viewGroup: ViewGroup): Observable { + const updateGroup = new Group(); + updateGroup.patchValues(viewGroup.group); + updateGroup.patchValues(groupData); + return this.dataSend.updateModel(updateGroup, 'put'); + } + + /** + * Deletes a given group + */ + public delete(viewGroup: ViewGroup): Observable { + return this.dataSend.delete(viewGroup.group); + } + + public createViewModel(group: Group): ViewGroup { + return new ViewGroup(group); + } +} diff --git a/client/src/app/site/users/services/user-repository.service.ts b/client/src/app/site/users/services/user-repository.service.ts index 735b16ffc..2d14e5c9a 100644 --- a/client/src/app/site/users/services/user-repository.service.ts +++ b/client/src/app/site/users/services/user-repository.service.ts @@ -46,9 +46,7 @@ export class UserRepositoryService extends BaseRepository { } /** - * @ignore - * - * TODO: used over not-yet-existing detail view + * Deletes a given user */ public delete(viewUser: ViewUser): Observable { return this.dataSend.delete(viewUser.user); @@ -65,7 +63,7 @@ export class UserRepositoryService extends BaseRepository { // collectionString of userData is still empty newUser.patchValues(userData); - // if the username is not presend, delete. + // if the username is not present, delete. // The server will generate a one if (!newUser.username) { delete newUser.username; diff --git a/client/src/app/site/users/users-routing.module.ts b/client/src/app/site/users/users-routing.module.ts index 802e8256d..879acea2e 100644 --- a/client/src/app/site/users/users-routing.module.ts +++ b/client/src/app/site/users/users-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { UserListComponent } from './components/user-list/user-list.component'; import { UserDetailComponent } from './components/user-detail/user-detail.component'; +import { GroupListComponent } from './components/group-list/group-list.component'; const routes: Routes = [ { @@ -12,6 +13,17 @@ const routes: Routes = [ path: 'new', component: UserDetailComponent }, + { + path: 'groups', + component: GroupListComponent + /** + * FIXME: CRITICAL: + * Refreshing the page, even while having the required permission, will navigate you back to "/" + * Makes developing protected areas impossible. + * Has the be (temporarily) removed if this page should be edited. + */ + // data: { basePerm: 'users.can_manage' } + }, { path: ':id', component: UserDetailComponent diff --git a/client/src/app/site/users/users.module.ts b/client/src/app/site/users/users.module.ts index d40a610ab..9306149c0 100644 --- a/client/src/app/site/users/users.module.ts +++ b/client/src/app/site/users/users.module.ts @@ -5,9 +5,10 @@ import { UsersRoutingModule } from './users-routing.module'; import { SharedModule } from '../../shared/shared.module'; import { UserListComponent } from './components/user-list/user-list.component'; import { UserDetailComponent } from './components/user-detail/user-detail.component'; +import { GroupListComponent } from './components/group-list/group-list.component'; @NgModule({ imports: [CommonModule, UsersRoutingModule, SharedModule], - declarations: [UserListComponent, UserDetailComponent] + declarations: [UserListComponent, UserDetailComponent, GroupListComponent] }) export class UsersModule {} diff --git a/client/src/assets/i18n/de.json b/client/src/assets/i18n/de.json index 9b2afab4d..dcd485359 100644 --- a/client/src/assets/i18n/de.json +++ b/client/src/assets/i18n/de.json @@ -1,8 +1,6 @@ { "Abort": "", "About Me": "", - "Agenda": "Tagesordnung", - "Assignments": "Wahlen", "Category": "", "Change Password": "Passwort ändern", "Changed version": "", @@ -35,13 +33,11 @@ } }, "FILTER": "", - "Files": "Dateien", "Final version": "", "First Name": "", "French": "Französisch", "German": "Deutsch", "Groups": "", - "Home": "Startseite", "Identifier": "", "Initial Password": "", "Inline": "", @@ -71,7 +67,6 @@ "Original version": "", "Outside": "", "Participant Number": "", - "Participants": "Teilnehmer", "Personal Note": "", "Personal note": "", "Prefix": "", @@ -85,7 +80,6 @@ "Reset recommendation": "", "SORT": "", "Selected Values": "", - "Settings": "Einstellungen", "State": "", "Structure Level": "", "Submitters": "", diff --git a/client/src/assets/i18n/en.json b/client/src/assets/i18n/en.json index 93643c7e3..bfeb06d5c 100644 --- a/client/src/assets/i18n/en.json +++ b/client/src/assets/i18n/en.json @@ -1,8 +1,6 @@ { "Abort": "", "About Me": "", - "Agenda": "", - "Assignments": "", "Category": "", "Change Password": "", "Changed version": "", @@ -35,13 +33,11 @@ } }, "FILTER": "", - "Files": "", "Final version": "", "First Name": "", "French": "", "German": "", "Groups": "", - "Home": "", "Identifier": "", "Initial Password": "", "Inline": "", @@ -71,7 +67,6 @@ "Original version": "", "Outside": "", "Participant Number": "", - "Participants": "", "Personal Note": "", "Personal note": "", "Prefix": "", @@ -85,7 +80,6 @@ "Reset recommendation": "", "SORT": "", "Selected Values": "", - "Settings": "", "State": "", "Structure Level": "", "Submitters": "", diff --git a/client/src/assets/i18n/fr.json b/client/src/assets/i18n/fr.json index 93643c7e3..bfeb06d5c 100644 --- a/client/src/assets/i18n/fr.json +++ b/client/src/assets/i18n/fr.json @@ -1,8 +1,6 @@ { "Abort": "", "About Me": "", - "Agenda": "", - "Assignments": "", "Category": "", "Change Password": "", "Changed version": "", @@ -35,13 +33,11 @@ } }, "FILTER": "", - "Files": "", "Final version": "", "First Name": "", "French": "", "German": "", "Groups": "", - "Home": "", "Identifier": "", "Initial Password": "", "Inline": "", @@ -71,7 +67,6 @@ "Original version": "", "Outside": "", "Participant Number": "", - "Participants": "", "Personal Note": "", "Personal note": "", "Prefix": "", @@ -85,7 +80,6 @@ "Reset recommendation": "", "SORT": "", "Selected Values": "", - "Settings": "", "State": "", "Structure Level": "", "Submitters": "",