diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 57ef4e1a..5cc6cb67 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -945,21 +945,55 @@ export const de = { InheritedTooltip: 'Diese Rollenzuweisung existiert implizit aufgrund der Zugehörigkeit zu einer anderen Resource', ScopeType: { Folder: { + Tooltip: 'Ordner', NotInherited: 'Zuweisung liegt auf diesem Ordner' }, Organization: { + Tooltip: 'Organisation', NotInherited: 'Zuweisung liegt auf dieser Organisation' }, Tournament: { + Tooltip: 'Turnier', NotInherited: 'Zuweisung liegt auf diesem Turnier' }, Venue: { + Tooltip: 'Spielstätte', NotInherited: 'Zuweisung liegt auf dieser Spielstätte' } }, - SuccessToast: { + DeletedSuccessToast: { Title: 'Rollenzuweisung gelöscht', Message: 'Die Rollenzuweisung wurde erfolgreich gelöscht.' + }, + AddRoleAssignment: { + Title: 'Rollenzuweisung hinzufügen', + PreviousStep: 'Zurück', + NextStep: 'Weiter', + StepTitle: { + SelectRole: 'Rolle selektieren', + SelectPrincipal: 'Prinzipal selektieren' + }, + AddAssignmentInfo: + 'Suchen Sie den gewünschten Nutzer oder API-Schlüssel anhand von E-Mailadresse bzw. API-Schlüssel-ID. Durch Klick auf "Suchen & hinzufügen" wird der spezifizierte Prinzipal gesucht und - sofern gefunden - eine entsprechende Rollenzuweisung erstellt.', + SelectedRole: 'Gewählte Rolle:', + SearchPrincipalPlaceholder: { + ApiKey: 'ID des API-Schlüssels eingeben', + User: 'E-Mailadresse des Nutzers eingeben' + }, + SearchPrincipalButton: 'Suchen & hinzufügen', + CreatingRoleAssignment: 'Die Rollenzuweisung wird erstellt', + CreateSuccessToast: { + Title: 'Rollenzuweisung erstellt', + Message: 'Die Rollenzuweisung wurde erfolgreich erstellt.' + }, + PrincipalNotFoundToast: { + Title: 'Rollenzuweisung konnte nicht erstellt werden', + Message: 'Es existiert kein Prinzipal mit der angegebenen Api-Key-ID bzw. E-Mailadresse.' + }, + AssignmentAlreadyExists: { + Title: 'Rollenzuweisung konnte nicht erstellt werden', + Message: 'Die angegebene Rolle ist bereits für den spezifizierten Prinzipal zugewiesen.' + } } } } diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.html b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.html new file mode 100644 index 00000000..70413b39 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.html @@ -0,0 +1,119 @@ + + + diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.scss b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.scss new file mode 100644 index 00000000..69cfdd44 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.scss @@ -0,0 +1,3 @@ +.modal-body { + min-height: 20em; +} diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.ts new file mode 100644 index 00000000..56b1ab09 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.ts @@ -0,0 +1,108 @@ +import { Component, OnDestroy } from '@angular/core'; +import { CreateRoleAssignmentEndpointRequest, PrincipalKind, Role, RoleAssignmentsService } from '../../../api'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { finalize, Observable, Subject } from 'rxjs'; +import { NotificationService } from '../../../core/services/notification.service'; + +type Step = 'SelectRole' | 'SelectPrincipal'; + +@Component({ + standalone: false, + templateUrl: './rbac-add-assignment.component.html', + styleUrl: './rbac-add-assignment.component.scss' +}) +export class RbacAddAssignmentComponent implements OnDestroy { + protected readonly PrincipalKind = PrincipalKind; + protected readonly availableRoles = Object.keys(Role) as Role[]; + + protected step: Step = 'SelectRole'; + protected selectedRole?: Role = undefined; + protected selectedPrincipalKind: PrincipalKind = PrincipalKind.User; + protected searchPrincipalInput: string = ''; + protected isCreatingRoleAssignment = false; + + private readonly errorSubject$ = new Subject(); + private targetScopeId: string = ''; + + constructor( + protected readonly modal: NgbActiveModal, + private readonly roleAssignmentsService: RoleAssignmentsService, + private readonly notificationService: NotificationService + ) {} + + public set scopeId(value: string) { + this.targetScopeId = value; + } + + public get error$(): Observable { + return this.errorSubject$.asObservable(); + } + + public ngOnDestroy(): void { + this.errorSubject$.complete(); + } + + protected previousStep(): void { + if (this.step === 'SelectPrincipal') { + this.step = 'SelectRole'; + } + } + + protected nextStep(): void { + if (this.step === 'SelectRole' && this.selectedRole !== undefined) { + this.step = 'SelectPrincipal'; + } + } + + protected selectRole(role: Role): void { + this.selectedRole = role; + this.step = 'SelectPrincipal'; + } + + protected addRoleAssignment(): void { + if (this.isCreatingRoleAssignment || this.selectedRole === undefined || this.searchPrincipalInput.trim().length === 0) { + return; + } + + this.isCreatingRoleAssignment = true; + + const request: CreateRoleAssignmentEndpointRequest = { + scopeId: this.targetScopeId, + role: this.selectedRole, + description: null, // TODO + apiKeyId: this.selectedPrincipalKind === PrincipalKind.ApiKey ? this.searchPrincipalInput.trim() : null, + userEmail: this.selectedPrincipalKind === PrincipalKind.User ? this.searchPrincipalInput.trim() : null + }; + + this.roleAssignmentsService + .createRoleAssignment({ body: request }) + .pipe(finalize(() => (this.isCreatingRoleAssignment = false))) + .subscribe({ + next: () => { + this.notificationService.showNotification( + 'success', + 'Portal.RbacManagement.AddRoleAssignment.CreateSuccessToast.Title', + 'Portal.RbacManagement.AddRoleAssignment.CreateSuccessToast.Message' + ); + this.modal.close(); + }, + error: (error: { status?: number }) => { + if (error.status === 400) { + this.notificationService.showNotification( + 'error', + 'Portal.RbacManagement.AddRoleAssignment.PrincipalNotFoundToast.Title', + 'Portal.RbacManagement.AddRoleAssignment.PrincipalNotFoundToast.Message' + ); + } else if (error.status === 409) { + this.notificationService.showNotification( + 'error', + 'Portal.RbacManagement.AddRoleAssignment.AssignmentAlreadyExists.Title', + 'Portal.RbacManagement.AddRoleAssignment.AssignmentAlreadyExists.Message' + ); + } else { + this.errorSubject$.next(error); + } + } + }); + } +} diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.html b/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.html index d28ed5a3..f817d42b 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.html @@ -7,12 +7,16 @@ } @else {
- + {{ target.name }} - +
@@ -30,16 +34,7 @@
- @switch (assignment.principal.kind) { - @case (PrincipalKind.ApiKey) { - - } - @case (PrincipalKind.User) { - - } - } - - {{ assignment.principal.principalId }} + @if (canDeleteAssignment(assignment)) { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts index 52f12d92..2fcf28a4 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts @@ -1,7 +1,9 @@ import { Component, OnDestroy } from '@angular/core'; import { finalize, Observable, Subject } from 'rxjs'; -import { PrincipalKind, Role, RoleAssignmentDto, RoleAssignmentsService } from '../../../api'; +import { Role, RoleAssignmentDto, RoleAssignmentsService } from '../../../api'; import { NotificationService } from '../../../core/services/notification.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { RbacAddAssignmentComponent } from '../rbac-add-assignment/rbac-add-assignment.component'; interface IRbacOffcanvasTarget { name: string; @@ -13,8 +15,6 @@ interface IRbacOffcanvasTarget { templateUrl: './rbac-offcanvas.component.html' }) export class RbacOffcanvasComponent implements OnDestroy { - protected readonly PrincipalKind = PrincipalKind; - protected target!: IRbacOffcanvasTarget; protected targetIcon: string = ''; protected isLoadingRoleAssignments = false; @@ -28,7 +28,8 @@ export class RbacOffcanvasComponent implements OnDestroy { constructor( private readonly roleAssignmentsService: RoleAssignmentsService, - private readonly notificationService: NotificationService + private readonly notificationService: NotificationService, + private readonly modalService: NgbModal ) {} public get error$(): Observable { @@ -64,30 +65,7 @@ export class RbacOffcanvasComponent implements OnDestroy { break; } - this.isLoadingRoleAssignments = true; - - this.roleAssignmentsService - .getRoleAssignments({ scopeId: this.target.rbacScopeId }) - .pipe(finalize(() => (this.isLoadingRoleAssignments = false))) - .subscribe({ - next: (roleAssignments) => { - this.roleAssignments = {}; - this.roleAssignmentCount = 0; - - for (const roleAssignment of roleAssignments) { - this.roleAssignmentCount++; - - if (roleAssignment.role in this.roleAssignments) { - this.roleAssignments[roleAssignment.role].push(roleAssignment); - } else { - this.roleAssignments[roleAssignment.role] = [roleAssignment]; - } - } - }, - error: (error) => { - this.errorSubject$.next(error); - } - }); + this.loadRoleAssignments(); } protected removeRoleAssignment(id: string): void { @@ -108,8 +86,8 @@ export class RbacOffcanvasComponent implements OnDestroy { this.notificationService.showNotification( 'success', - 'Portal.RbacManagement.SuccessToast.Title', - 'Portal.RbacManagement.SuccessToast.Message' + 'Portal.RbacManagement.DeletedSuccessToast.Title', + 'Portal.RbacManagement.DeletedSuccessToast.Message' ); }, error: (error) => { @@ -137,4 +115,46 @@ export class RbacOffcanvasComponent implements OnDestroy { name: scopeName }); } + + protected showAddRoleAssignmentDialog(): void { + const ref = this.modalService.open(RbacAddAssignmentComponent, { + size: 'lg', + fullscreen: 'lg', + centered: true + }); + + const component = ref.componentInstance as RbacAddAssignmentComponent; + + component.scopeId = this.target.rbacScopeId; + component.error$.subscribe((error) => this.errorSubject$.next(error)); + + ref.closed.subscribe(() => this.loadRoleAssignments()); + } + + private loadRoleAssignments(): void { + this.isLoadingRoleAssignments = true; + + this.roleAssignmentsService + .getRoleAssignments({ scopeId: this.target.rbacScopeId }) + .pipe(finalize(() => (this.isLoadingRoleAssignments = false))) + .subscribe({ + next: (roleAssignments) => { + this.roleAssignments = {}; + this.roleAssignmentCount = 0; + + for (const roleAssignment of roleAssignments) { + this.roleAssignmentCount++; + + if (roleAssignment.role in this.roleAssignments) { + this.roleAssignments[roleAssignment.role].push(roleAssignment); + } else { + this.roleAssignments[roleAssignment.role] = [roleAssignment]; + } + } + }, + error: (error) => { + this.errorSubject$.next(error); + } + }); + } } diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-principal/rbac-principal.component.html b/src/Turnierplan.App/Client/src/app/portal/components/rbac-principal/rbac-principal.component.html new file mode 100644 index 00000000..d3d7f345 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-principal/rbac-principal.component.html @@ -0,0 +1,10 @@ +@switch (principal.kind) { + @case (PrincipalKind.ApiKey) { + + } + @case (PrincipalKind.User) { + + } +} + +{{ principal.principalId }} diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-principal/rbac-principal.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/rbac-principal/rbac-principal.component.ts new file mode 100644 index 00000000..63a450ba --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-principal/rbac-principal.component.ts @@ -0,0 +1,14 @@ +import { Component, Input } from '@angular/core'; +import { PrincipalDto, PrincipalKind } from '../../../api'; + +@Component({ + selector: 'tp-rbac-principal', + standalone: false, + templateUrl: './rbac-principal.component.html' +}) +export class RbacPrincipalComponent { + protected readonly PrincipalKind = PrincipalKind; + + @Input() + public principal!: PrincipalDto; +} diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.ts index ba95cf8c..e86989b2 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.ts @@ -1,6 +1,9 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap'; +import { NgbOffcanvas, NgbOffcanvasRef } from '@ng-bootstrap/ng-bootstrap'; import { RbacOffcanvasComponent } from '../rbac-offcanvas/rbac-offcanvas.component'; +import { NavigationStart, Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs/operators'; interface IRbacWidgetTarget { name: string; @@ -25,15 +28,31 @@ export class RbacWidgetComponent { @Output() public errorOccured = new EventEmitter(); - constructor(private readonly offcanvasService: NgbOffcanvas) {} + private offcanvas?: NgbOffcanvasRef; + + constructor( + private readonly offcanvasService: NgbOffcanvas, + private readonly router: Router + ) { + this.router.events + .pipe( + takeUntilDestroyed(), + filter((event) => event instanceof NavigationStart) + ) + .subscribe({ + next: () => { + this.offcanvas?.close(); + } + }); + } protected buttonClicked(): void { - const ref = this.offcanvasService.open(RbacOffcanvasComponent, { position: 'end' }); - const component = ref.componentInstance as RbacOffcanvasComponent; + this.offcanvas = this.offcanvasService.open(RbacOffcanvasComponent, { position: 'end' }); + const component = this.offcanvas.componentInstance as RbacOffcanvasComponent; component.error$.subscribe({ next: (value) => { - ref.close(); + this.offcanvas?.close(); this.errorOccured.emit(value); } }); diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts index ecbda077..de138961 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts @@ -9,6 +9,8 @@ import { NotificationService } from '../../../core/services/notification.service import { PageFrameNavigationTab } from '../../components/page-frame/page-frame.component'; import { LoadingState } from '../../directives/loading-state/loading-state.directive'; import { TitleService } from '../../services/title.service'; +import { NavigationStart, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; @Component({ standalone: false, @@ -35,9 +37,21 @@ export class AdministrationPageComponent implements OnInit { private readonly titleService: TitleService, private readonly authenticationService: AuthenticationService, private readonly offcanvasService: NgbOffcanvas, - private readonly notificationService: NotificationService + private readonly notificationService: NotificationService, + private readonly router: Router ) { this.authenticationService.authentication$.pipe(takeUntilDestroyed()).subscribe((userInfo) => (this.currentUserId = userInfo.id)); + + this.router.events + .pipe( + takeUntilDestroyed(), + filter((event) => event instanceof NavigationStart) + ) + .subscribe({ + next: () => { + this.currentOffcanvas?.close(); + } + }); } public ngOnInit(): void { diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.module.ts b/src/Turnierplan.App/Client/src/app/portal/portal.module.ts index 302df2e2..183ef7b4 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.module.ts +++ b/src/Turnierplan.App/Client/src/app/portal/portal.module.ts @@ -81,6 +81,8 @@ import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2 import { QRCodeComponent } from 'angularx-qrcode'; import { RbacWidgetComponent } from './components/rbac-widget/rbac-widget.component'; import { RbacOffcanvasComponent } from './components/rbac-offcanvas/rbac-offcanvas.component'; +import { RbacPrincipalComponent } from './components/rbac-principal/rbac-principal.component'; +import { RbacAddAssignmentComponent } from './components/rbac-add-assignment/rbac-add-assignment.component'; const routes: Routes = [ { @@ -223,7 +225,9 @@ const routes: Routes = [ CreateUserComponent, BadgeComponent, RbacWidgetComponent, - RbacOffcanvasComponent + RbacOffcanvasComponent, + RbacAddAssignmentComponent, + RbacPrincipalComponent ], imports: [ CommonModule, diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs index ddd14de3..82acdbcf 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs @@ -144,7 +144,7 @@ public sealed record CreateRoleAssignmentEndpointRequest public required string? UserEmail { get; init; } - public required string Description { get; init; } + public required string? Description { get; init; } } private sealed class Validator : AbstractValidator @@ -164,7 +164,9 @@ private Validator() .WithMessage($"Exactly only one of {nameof(CreateRoleAssignmentEndpointRequest.ApiKeyId)} and {nameof(CreateRoleAssignmentEndpointRequest.UserEmail)} must be specified."); RuleFor(x => x.Description) - .MaximumLength(ValidationConstants.RoleAssignment.MaxDescriptionLength); + .NotEmpty() + .MaximumLength(ValidationConstants.RoleAssignment.MaxDescriptionLength) + .When(x => x.Description is not null); } } } diff --git a/src/Turnierplan.App/Helpers/DeletionHelper.cs b/src/Turnierplan.App/Helpers/DeletionHelper.cs index 66bef579..d1c1b1b4 100644 --- a/src/Turnierplan.App/Helpers/DeletionHelper.cs +++ b/src/Turnierplan.App/Helpers/DeletionHelper.cs @@ -45,6 +45,7 @@ public DeletionHelper( public async Task DeleteUserAsync(User user, CancellationToken cancellationToken) { // TODO: Decide how to handle this (there is no longer a 1-n relation between user and organisation) + // When this is decided: Update the information text on the "delete user" screen in the frontend // foreach (var organization in user.Organizations.ToList()) // ToList() to avoid invalid operation exception // {