From efbfc2eb011938ea729a469c42ffb447d48aef6f Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 2 Jan 2025 13:16:45 +0100 Subject: [PATCH] Manage SystemManagers --- .../repositories/user.repository.mock.ts | 6 +- api/src/model/entitites/sso.user.entity.ts | 2 +- api/src/model/entitites/user.entity.ts | 10 ++- api/src/model/interface/user.interface.ts | 2 + api/src/model/repositories/user.repository.ts | 12 +++ api/src/modules/system/system.controller.ts | 5 ++ api/src/modules/system/system.service.ts | 36 ++++++++- api/src/modules/user/user.controller.ts | 13 +++- api/src/modules/user/user.service.ts | 5 ++ client/mocks/services/mock.auth.service.ts | 5 ++ .../admin/all-users/all-users.component.ts | 48 +++++++++++- .../select-key-cylinder.component.ts | 5 +- .../system-manager.component.html | 18 ++++- .../system-manager.component.spec.ts | 8 +- .../system-manager.component.ts | 76 ++++++++++++++++++- .../ag-system-manager.component.ts | 2 +- client/src/app/shared/api.service.ts | 18 +++++ client/src/styles.scss | 18 +++++ 18 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 client/mocks/services/mock.auth.service.ts diff --git a/api/mocks/repositories/user.repository.mock.ts b/api/mocks/repositories/user.repository.mock.ts index f816744..4c8c03f 100644 --- a/api/mocks/repositories/user.repository.mock.ts +++ b/api/mocks/repositories/user.repository.mock.ts @@ -19,7 +19,8 @@ export class MockUserRepository { }, isActive: false, role: null, - systems: [] + systems: [], + deletedAt: null } findByUsername = jest.fn().mockImplementation((username: string) => { @@ -42,7 +43,8 @@ export class MockUserRepository { }, isActive: false, role: null, - systems: [] + systems: [], + deletedAt: null } return user; }) diff --git a/api/src/model/entitites/sso.user.entity.ts b/api/src/model/entitites/sso.user.entity.ts index bce1d09..463184f 100644 --- a/api/src/model/entitites/sso.user.entity.ts +++ b/api/src/model/entitites/sso.user.entity.ts @@ -6,7 +6,7 @@ export class SSOUser { @PrimaryColumn({ type: 'uuid', unique: true }) externalId: string; - @OneToOne(() => User, (user) => user.external) + @OneToOne(() => User, (user) => user.external, { onDelete: 'CASCADE'}) @JoinColumn() user: User; diff --git a/api/src/model/entitites/user.entity.ts b/api/src/model/entitites/user.entity.ts index d7cbc65..a94cbb8 100644 --- a/api/src/model/entitites/user.entity.ts +++ b/api/src/model/entitites/user.entity.ts @@ -2,6 +2,7 @@ import { Exclude, Transform } from 'class-transformer'; import { Column, CreateDateColumn, + DeleteDateColumn, Entity, JoinColumn, ManyToMany, @@ -21,7 +22,7 @@ export class User implements IUser { id: string; @IsEmail() - @Column({ unique: true }) + @Column({ unique: false }) username: string; @Column({ name: 'first_name', default: '' }) @@ -37,13 +38,13 @@ export class User implements IUser { lastLogin: Date; @Exclude() - @OneToOne(() => SSOUser, (sso) => sso.user, { eager: true, cascade: true }) + @OneToOne(() => SSOUser, (sso) => sso.user, { eager: true, cascade: true, onDelete: 'CASCADE' }) external: SSOUser; @Column({ default: true }) isActive: boolean; - @ManyToOne(() => Role, (role) => role.user, { cascade: true, eager: true }) + @ManyToOne(() => Role, (role) => role.user, { eager: true, onDelete: 'NO ACTION' }) @JoinColumn() @Transform(({ value }) => value.name) role: Role; @@ -51,6 +52,9 @@ export class User implements IUser { @ManyToMany(() => KeySystem, (system) => system.managers) systems: KeySystem[]; + @DeleteDateColumn() + deletedAt: Date; + accessToken?: string; refreshToken?: string; } diff --git a/api/src/model/interface/user.interface.ts b/api/src/model/interface/user.interface.ts index 64448d7..82bcb1e 100644 --- a/api/src/model/interface/user.interface.ts +++ b/api/src/model/interface/user.interface.ts @@ -10,5 +10,7 @@ export interface IUser { accessToken?: string; refreshToken?: string; + deletedAt?: Date; + role?: string | Role; } diff --git a/api/src/model/repositories/user.repository.ts b/api/src/model/repositories/user.repository.ts index 5023142..da14e73 100644 --- a/api/src/model/repositories/user.repository.ts +++ b/api/src/model/repositories/user.repository.ts @@ -61,4 +61,16 @@ export class UserRepository extends Repository { const sso = await this.ssoRepo.findByExternalId(externalId); return user == null && sso == null; } + + async deleteUserById(id: string) { + const user = await this.findOne({ + where: { id }, + relations: ['external'] + }); + + if (user.external) { + await this.ssoRepo.remove(user.external); + } + return this.softRemove(user) + } } diff --git a/api/src/modules/system/system.controller.ts b/api/src/modules/system/system.controller.ts index 366942e..8f83056 100644 --- a/api/src/modules/system/system.controller.ts +++ b/api/src/modules/system/system.controller.ts @@ -52,4 +52,9 @@ export class SystemController { remove(@Param('id') id: string) { return this.systemService.remove(id); } + + @Post(':id/manager') + manaManager(@Param('id') id: string, @Body() body: any){ + return this.systemService.manageManagers(id, body); + } } diff --git a/api/src/modules/system/system.service.ts b/api/src/modules/system/system.service.ts index 8a41023..4cf7622 100644 --- a/api/src/modules/system/system.service.ts +++ b/api/src/modules/system/system.service.ts @@ -1,12 +1,12 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { CreateSystemDto } from './dto/create-system.dto'; import { UpdateSystemDto } from './dto/update-system.dto'; -import { KeySystemRepository } from 'src/model/repositories'; +import { KeySystemRepository, UserRepository } from 'src/model/repositories'; import { User } from 'src/model/entitites'; @Injectable() export class SystemService { - constructor(private systemRepo: KeySystemRepository) {} + constructor(private systemRepo: KeySystemRepository, private userRepo: UserRepository) {} async create(user: User, createSystemDto: CreateSystemDto) { const sys = this.systemRepo.create(createSystemDto); @@ -52,4 +52,36 @@ export class SystemService { return system.managers; } + + async manageManagers(systemID: string, manageObject: { email: string, action: 'add' | 'remove'}) { + const sys = await this.systemRepo.findOne({ + where: { id: systemID }, + relations: ['managers'] + }); + + if (!sys) { + throw new HttpException('Das System wurde nicht im System gefunden', HttpStatus.NOT_FOUND); + } + + if (manageObject.action == 'remove') { + sys.managers = sys.managers.filter( m => m.username != manageObject.email); + + await this.systemRepo.save(sys); + return sys.managers; + } + + if (sys.managers.some(m => m.username == manageObject.email)) { + return sys.managers; + } + + const user = await this.userRepo.findOneBy({ username: manageObject.email.trim() }); + if (!user) { + throw new HttpException('Es wurde kein User mit dieser Emailadresse gefunden. Bitte prüfe die Emailadresse und versuche es erneut.', HttpStatus.NOT_FOUND); + } + + sys.managers.push(user); + await this.systemRepo.save(sys); + return sys.managers; + + } } diff --git a/api/src/modules/user/user.controller.ts b/api/src/modules/user/user.controller.ts index 3273737..c3c54a1 100644 --- a/api/src/modules/user/user.controller.ts +++ b/api/src/modules/user/user.controller.ts @@ -1,8 +1,11 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpException, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common'; import { AuthGuard } from 'src/core/guards/auth.guard'; import { UserService } from './user.service'; import { User } from 'src/model/entitites'; import { IUser } from 'src/model/interface'; +import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface'; +import { HttpErrorByCode } from '@nestjs/common/utils/http-error-by-code.util'; +import { HttpStatusCode } from 'axios'; @UseGuards(AuthGuard) @Controller('user') @@ -18,4 +21,12 @@ export class UserController { saveUser(@Body() user: IUser) { return this.userService.saveUser(user); } + + @Delete(':id') + deleteUserWithId(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + if (req.user.role.name != "admin") { + throw new HttpException('no admin', HttpStatus.UNAUTHORIZED); + } + return this.userService.deleteUserById(id); + } } diff --git a/api/src/modules/user/user.service.ts b/api/src/modules/user/user.service.ts index 7bb960e..de41de7 100644 --- a/api/src/modules/user/user.service.ts +++ b/api/src/modules/user/user.service.ts @@ -23,4 +23,9 @@ export class UserService { } return this.userRepo.save(user as any); } + + async deleteUserById(id: string) { + return this.userRepo.deleteUserById(id); + } + } diff --git a/client/mocks/services/mock.auth.service.ts b/client/mocks/services/mock.auth.service.ts new file mode 100644 index 0000000..ea34476 --- /dev/null +++ b/client/mocks/services/mock.auth.service.ts @@ -0,0 +1,5 @@ +import { of } from "rxjs"; + +export class MockAuthService { + user = { id: '1', username: 'test', role: 'admin' }; +} \ No newline at end of file diff --git a/client/src/app/modules/admin/all-users/all-users.component.ts b/client/src/app/modules/admin/all-users/all-users.component.ts index 43b3ef0..6783f94 100644 --- a/client/src/app/modules/admin/all-users/all-users.component.ts +++ b/client/src/app/modules/admin/all-users/all-users.component.ts @@ -7,11 +7,12 @@ import { HotToastService } from '@ngxpert/hot-toast'; import { AuthService } from '../../../core/auth/auth.service'; import { DatePipe } from '@angular/common'; import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale'; +import { MatButtonModule } from '@angular/material/button'; @Component({ selector: 'app-all-users', standalone: true, - imports: [AgGridAngular], + imports: [AgGridAngular, MatButtonModule], providers: [DatePipe], templateUrl: './all-users.component.html', styleUrl: './all-users.component.scss' @@ -54,7 +55,26 @@ export class AllUsersComponent { , width: 170 , type: 'date' , cellRenderer: (data: any) => data.value ? this.datePipe.transform(new Date(data.value), 'medium') : '-' - } + }, + { + field: 'delete', + headerName: '', + width: 50, + cellRenderer: (params: any) => { + if (params.data.id == this.authService.user.id || !this.authService.isAdmin) { + return ''; + } + return `
`; + }, + onCellClicked: (event: any) => { + if (event.data.id == this.authService.user.id || !this.authService.isAdmin) { + return; + } + if (event.colDef.field == 'delete') { + this.deleteUser(event.data.id); + } + }, + } ], loading: true, overlayLoadingTemplate: 'Lade Daten...' @@ -64,12 +84,36 @@ export class AllUsersComponent { } + deleteUser(id: string) { + if ( confirm('Soll der Benutzer wirklich gelöscht werden?')) { + this.api.deleteUser(id) + .pipe( + this.toast.observe({ + loading: 'löschen...', + success: 'Benutzer gelöscht', + error: 'Benutzer konnte nicht gelöscht werden!' + }) + ) + .subscribe({ + next: () => { + this.loadUsers(); + } + }) + + } + } + loadUsers() { + this.gridApi.setGridOption("loading", true); this.api.getAllUsers().subscribe({ next: n => { this.gridApi.setGridOption("rowData", n) this.gridApi.setGridOption("loading", false); + }, + error: () => { + this.toast.error('Benutzer konnten nicht geladen werden!') + this.gridApi.setGridOption("loading", false); } }) } diff --git a/client/src/app/modules/keys/create/select-key-cylinder/select-key-cylinder.component.ts b/client/src/app/modules/keys/create/select-key-cylinder/select-key-cylinder.component.ts index 72e2188..19f8b25 100644 --- a/client/src/app/modules/keys/create/select-key-cylinder/select-key-cylinder.component.ts +++ b/client/src/app/modules/keys/create/select-key-cylinder/select-key-cylinder.component.ts @@ -31,17 +31,16 @@ export class SelectKeyCylinderComponent { }, rowSelection: 'multiple', columnDefs: [ - // selected rows { colId: 'selected', headerName: '', checkboxSelection: true, width: 40, headerCheckboxSelection: true, headerCheckboxSelectionFilteredOnly: true }, { colId: 'name', field: 'name' , headerName: 'Name', flex: 1, editable: true, sort: 'asc', filter: true }, + { colId: 'system', field: 'system' , headerName: 'Schließanlage', flex: 1, editable: false, filter: true, cellRenderer: (data: any) => {return data.value?.name} }, ] }; selectedCylinders: ICylinder[] = []; ngOnInit(): void { - console.log(this.toast) - this.toast.error('Wähle die Zylinder aus, die dem Schlüssel zugeordnet werden sollen.'); + this.toast.info('Wähle die Zylinder aus, die dem Schlüssel zugeordnet werden sollen.'); } onGridReady(params: GridReadyEvent) { diff --git a/client/src/app/modules/system/components/system-manager/system-manager.component.html b/client/src/app/modules/system/components/system-manager/system-manager.component.html index 72e8fe1..17fecf4 100644 --- a/client/src/app/modules/system/components/system-manager/system-manager.component.html +++ b/client/src/app/modules/system/components/system-manager/system-manager.component.html @@ -1,13 +1,25 @@ -

Manager

+

{{ system?.name }} - Manager

+
+
- + /> +
+ +
+ + Email + + Emailadresse des neuen Users eingeben + + +
+
diff --git a/client/src/app/modules/system/components/system-manager/system-manager.component.spec.ts b/client/src/app/modules/system/components/system-manager/system-manager.component.spec.ts index 532e7aa..350e35b 100644 --- a/client/src/app/modules/system/components/system-manager/system-manager.component.spec.ts +++ b/client/src/app/modules/system/components/system-manager/system-manager.component.spec.ts @@ -7,6 +7,9 @@ import { ApiService } from '../../../../shared/api.service'; import { HotToastService } from '@ngxpert/hot-toast'; import { MockApiService } from '../../../../../../mocks/services/mock.api.service'; import { GridReadyEvent } from 'ag-grid-community'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { MockAuthService } from '../../../../../../mocks/services/mock.auth.service'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -18,7 +21,7 @@ describe('SystemManagerComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SystemManagerComponent, AgGridAngular, MatDialogModule], + imports: [SystemManagerComponent, AgGridAngular, MatDialogModule, NoopAnimationsModule], providers: [ HotToastService, { provide: ApiService, useClass: MockApiService }, @@ -29,7 +32,8 @@ describe('SystemManagerComponent', () => { { provide: MAT_DIALOG_DATA, useValue: [] - } + }, + { provide: AuthService, useClass: MockAuthService } ] }) .compileComponents(); diff --git a/client/src/app/modules/system/components/system-manager/system-manager.component.ts b/client/src/app/modules/system/components/system-manager/system-manager.component.ts index c086a2d..9640801 100644 --- a/client/src/app/modules/system/components/system-manager/system-manager.component.ts +++ b/client/src/app/modules/system/components/system-manager/system-manager.component.ts @@ -5,11 +5,18 @@ import { AgGridAngular } from 'ag-grid-angular'; import { MatDialogRef, MAT_DIALOG_DATA, MatDialog, MatDialogModule } from '@angular/material/dialog'; import { HotToastService } from '@ngxpert/hot-toast'; import { ApiService } from '../../../../shared/api.service'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-system-manager', standalone: true, - imports: [AgGridAngular, MatDialogModule], + imports: [AgGridAngular, MatDialogModule, MatButtonModule, MatInputModule, MatFormFieldModule, CommonModule, FormsModule], templateUrl: './system-manager.component.html', styleUrl: './system-manager.component.scss' }) @@ -22,14 +29,32 @@ export class SystemManagerComponent { readonly system = inject(MAT_DIALOG_DATA); private api: ApiService = inject(ApiService); - private dialog: MatDialog = inject(MatDialog); - private toast = inject(HotToastService); + private dialog: MatDialog = inject(MatDialog); + private toast = inject(HotToastService); + private authService = inject(AuthService); + + email = null; constructor() { this.gridOptions.columnDefs = [ { colId: 'name', field: 'firstName', headerName: 'Name', sort: 'asc', flex: 1, cellRenderer: (data: any) => data.data.firstName + ' ' + data.data.lastName, sortable: true, filter: true}, { colId: 'mail', field: 'username', headerName: 'E-Mail', flex: 1}, + { colId: 'delete', headerName: '', width: 50, + cellRenderer: (params: any) => { + if (this.authService.user.username == params.data.username) return ''; + return `
`; + }, + onCellClicked: (event: any) => { + if (this.authService.user.username == event.data.username) { + this.toast.error('Du kannst dich nicht selbst entfernen'); + return; + } + if (event.colDef.colId == 'delete') { + this.removeManagerByEmail(event.data.username); + } + }, + } ] } @@ -47,4 +72,49 @@ export class SystemManagerComponent { } }) } + + addManagerByEmail() { + if (this.email == null) return; + this.gridApi.setGridOption("loading", true); + this.api.addManager(this.system.id, this.email) + .pipe( + this.toast.observe({ + loading: 'Füge Manager hinzu', + success: 'Manager hinzugefügt', + error: (x: any) => x.error.message || 'Fehler beim Hinzufügen des Managers' + }) + ).subscribe({ + next: n => { + this.gridApi.setGridOption("rowData", n); + this.email = null; + this.gridApi.setGridOption("loading", false); + }, + error: () => { + this.gridApi.setGridOption("loading", false); + } + }); + } + + removeManagerByEmail(username: string) { + const resume = confirm('Soll der Manager wirklich entfernt werden?'); + if (!resume) return; + + this.gridApi.setGridOption("loading", true); + this.api.removeManager(this.system.id, username) + .pipe( + this.toast.observe({ + loading: 'Manager entfernen', + success: 'Manager entfernt', + error: (x: any) => x.error.message || 'Fehler beim Entfgernen des Managers' + }) + ).subscribe({ + next: (n: any[]) => { + this.gridApi.setGridOption("rowData", n); + this.gridApi.setGridOption("loading", false); + }, + error: () => { + this.gridApi.setGridOption("loading", false); + } + }); + } } diff --git a/client/src/app/shared/ag-grid/components/ag-system-manager/ag-system-manager.component.ts b/client/src/app/shared/ag-grid/components/ag-system-manager/ag-system-manager.component.ts index 66cd19d..6626f78 100644 --- a/client/src/app/shared/ag-grid/components/ag-system-manager/ag-system-manager.component.ts +++ b/client/src/app/shared/ag-grid/components/ag-system-manager/ag-system-manager.component.ts @@ -41,7 +41,7 @@ export class AgSystemManagerComponent implements ICellRendererAngularComp { width: "50vw", minWidth: "300px", height: "70vh", - disableClose: true + disableClose: false }) // ref.afterClosed().subscribe({ diff --git a/client/src/app/shared/api.service.ts b/client/src/app/shared/api.service.ts index 2731329..4810258 100644 --- a/client/src/app/shared/api.service.ts +++ b/client/src/app/shared/api.service.ts @@ -85,4 +85,22 @@ export class ApiService { deleteCylinder(cylinder: ICylinder): Observable { return this.http.delete(`api/cylinder/${cylinder.id}`) } + + deleteUser(id: string) { + return this.http.delete(`api/user/${id}`) + } + + addManager(systemID: string, email: string): Observable { + return this.http.post(`api/system/${systemID}/manager`, { + email, + action: 'add' + }); + } + + removeManager(systemID: string, email: string): Observable { + return this.http.post(`api/system/${systemID}/manager`, { + email, + action: 'remove' + }); + } } diff --git a/client/src/styles.scss b/client/src/styles.scss index f46ec27..2666289 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -19,6 +19,24 @@ html, body { font-family: Roboto, "Helvetica Neue", sans-serif; } +.icon { + border: 1px solid #d0d5dd; + background-color: white; + cursor: pointer; + padding: 4px; + box-sizing: border-box; + border-radius: 6px; + background-position: center; + background-repeat: no-repeat; + transition: box-shadow 0.1s ease-in-out; + + &:hover { + // box-shadow: 0 0 0 4px transparent,0 1px 2px #0c111d11; + box-shadow: 0px 3px 5px -1px rgba(0, 0, 0, 0.2), + 0px 6px 10px 0px rgba(0, 0, 0, 0.14), + 0px 1px 18px 0px rgba(0, 0, 0, 0.12); + } +} .icon-btn-sm { // box-shadow: 0 0 0 4px transparent,0 1px 2px #0c111d11;