diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 55774f4a..6e9a94e0 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -211,6 +211,9 @@ export const de = { Message: 'Der API-Schlüssel wurde gelöscht und kann nun nicht mehr für Anfragen verwendet werden.' } }, + RbacWidget: { + Info: 'Verwalten Sie, welche Nutzer auf diese Organisation zugreifen können und welche Aktionen sie durchführen können.' + }, DeleteWidget: { Title: 'Organisation löschen', Info: 'Wenn Sie eine Organisation löschen, werden automatisch alle darin enthaltenen Turniere, Spielstätten sämtliche hochgeladenen Bilder mitgelöscht. Diese Aktion kann nicht widerrufen werden!', @@ -617,6 +620,9 @@ export const de = { NoVenues: 'In der aktuellen Organisation gibt es aktuell keine Spielstätten.', NoVenueOption: 'Keine Spielstätte verknüpfen' }, + RbacWidget: { + Info: 'Verwalten Sie, welche Nutzer auf dieses Turnier zugreifen können und welche Aktionen sie durchführen können.' + }, DeleteWidget: { Title: 'Turnier löschen', Info: 'Wenn Sie ein Turnier löschen, werden alle Mannschaften, Gruppen, Spiele und Ergebnisse gelöscht. Diese Aktion kann nicht widerrufen werden!', @@ -804,6 +810,9 @@ export const de = { Message: 'Ihre Änderungen an dieser Spielstätte wurden gespeichert' } }, + RbacWidget: { + Info: 'Verwalten Sie, welche Nutzer auf diese Spielstätte zugreifen können und welche Aktionen sie durchführen können.' + }, DeleteWidget: { Title: 'Spielstätte löschen', Info: 'Wenn Sie eine Spielstätte löschen, wird diese von allen derzeit verknüpften Turnieren entfernt. Diesen Turnieren kann anschließend eine andere Spielstätte zugewiesen werden.', @@ -908,6 +917,50 @@ export const de = { UnexpectedError: 'Beim Verarbeiten der Anfrage ist ein unerwarteter Server-Fehler aufgetreten.\nLaden Sie die Seite neu und versuchen Sie es erneut.', ErrorDescription: 'Fehlerbeschreibung:' + }, + RbacManagement: { + Title: 'Zugriff verwalten', + ButtonLabel: 'Verwalten', + OffcanvasTitle: 'Rollenzuweisungen bearbeiten', + Loading: 'Rollenzuweisungen werden geladen', + NewRoleAssignment: 'Neue Rollenzuweisung', + RoleName: { + Owner: 'Besitzer', + Contributor: 'Mitwirkender', + Reader: 'Leser' + }, + RoleDescription: { + Owner: 'Der Benutzer kann sämtliche Änderungen durchführen inkl. Änderungen an Zugriffsrechten.', + Contributor: 'Der Benutzer kann sämtliche Änderungen durchführen ausgenommen Änderungen an Zugriffsrechten.', + Reader: 'Der Benutzer kann sämtliche Informationen lesen aber keine Änderungen durchführen.' + }, + PrincipalKind: { + ApiKey: 'API-Schlüssel', + User: 'Benutzer' + }, + TotalCount: 'Gesamt:', + Id: 'ID:', + CreatedAt: 'Erstellt am:', + Inherited: 'Vererbt durch:', + InheritedTooltip: 'Diese Rollenzuweisung existiert implizit aufgrund der Zugehörigkeit zu einer anderen Resource', + ScopeType: { + Folder: { + NotInherited: 'Zuweisung liegt auf diesem Ordner' + }, + Organization: { + NotInherited: 'Zuweisung liegt auf dieser Organisation' + }, + Tournament: { + NotInherited: 'Zuweisung liegt auf diesem Turnier' + }, + Venue: { + NotInherited: 'Zuweisung liegt auf dieser Spielstätte' + } + }, + SuccessToast: { + Title: 'Rollenzuweisung gelöscht', + Message: 'Die Rollenzuweisung wurde erfolgreich gelöscht.' + } } } }; diff --git a/src/Turnierplan.App/Client/src/app/portal/components/folder-tree/folder-tree.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/folder-tree/folder-tree.component.ts index aa7e2320..4c7080c9 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/folder-tree/folder-tree.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/folder-tree/folder-tree.component.ts @@ -5,6 +5,7 @@ import { TournamentHeaderDto } from '../../../api'; export type FolderTreeEntry = { id: string; folderId?: string; + folderName?: string; label: string; isRoot: boolean; indentation: number; @@ -54,6 +55,7 @@ export class FolderTreeComponent { const folderEntry: FolderTreeEntry = { id: '/' + folder.id, folderId: folder.id, + folderName: folder.name, label: folder.name ?? '?', isRoot: false, indentation: 1, diff --git a/src/Turnierplan.App/Client/src/app/portal/components/loading-error/loading-error.component.html b/src/Turnierplan.App/Client/src/app/portal/components/loading-error/loading-error.component.html index 9f04657b..4a24f827 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/loading-error/loading-error.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/loading-error/loading-error.component.html @@ -1,4 +1,4 @@ -@if (statusCode === 404) { +@if (statusCode === 403 || statusCode === 404) { +
+ @if (isLoadingRoleAssignments) { +
+ + +
+ } @else { +
+ + + {{ target.name }} + + + +
+ + + + @for (group of roleAssignments | keyvalue; track group.key) { + + + + + @for (assignment of group.value; track assignment.id) { + + + + } + } + +
+ + +
+
+
+ @switch (assignment.principal.kind) { + @case (PrincipalKind.ApiKey) { + + } + @case (PrincipalKind.User) { + + } + } + + {{ assignment.principal.objectId }} + @if (canDeleteAssignment(assignment)) { + + + } +
+ @if (assignment.isInherited) { + + } @else { +
+ + +
+ } +
+ + {{ assignment.id }} + · + + {{ assignment.createdAt | translateDate }} +
+
+
+ + @if (roleAssignmentCount > 1) { +
+ + {{ roleAssignmentCount }} +
+ } + } + 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 new file mode 100644 index 00000000..52f12d92 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts @@ -0,0 +1,140 @@ +import { Component, OnDestroy } from '@angular/core'; +import { finalize, Observable, Subject } from 'rxjs'; +import { PrincipalKind, Role, RoleAssignmentDto, RoleAssignmentsService } from '../../../api'; +import { NotificationService } from '../../../core/services/notification.service'; + +interface IRbacOffcanvasTarget { + name: string; + rbacScopeId: string; +} + +@Component({ + standalone: false, + templateUrl: './rbac-offcanvas.component.html' +}) +export class RbacOffcanvasComponent implements OnDestroy { + protected readonly PrincipalKind = PrincipalKind; + + protected target!: IRbacOffcanvasTarget; + protected targetIcon: string = ''; + protected isLoadingRoleAssignments = false; + protected roleAssignments: { [key: string]: RoleAssignmentDto[] } = {}; + protected roleAssignmentCount: number = 0; + + protected scopeTranslationKey: string = ''; + + private readonly errorSubject$ = new Subject(); + private targetIsOrganization: boolean = false; + + constructor( + private readonly roleAssignmentsService: RoleAssignmentsService, + private readonly notificationService: NotificationService + ) {} + + public get error$(): Observable { + return this.errorSubject$.asObservable(); + } + + public ngOnDestroy(): void { + this.errorSubject$.complete(); + } + + public setTarget(target: IRbacOffcanvasTarget) { + this.target = target; + + const scopeType = target.rbacScopeId.substring(0, target.rbacScopeId.indexOf(':')); + this.targetIsOrganization = scopeType === 'Organization'; + this.scopeTranslationKey = `Portal.RbacManagement.ScopeType.${scopeType}`; + + switch (scopeType) { + case 'Folder': + this.targetIcon = 'folder2-open'; + break; + case 'Organization': + this.targetIcon = 'boxes'; + break; + case 'Tournament': + this.targetIcon = 'trophy'; + break; + case 'Venue': + this.targetIcon = 'buildings'; + break; + default: + this.targetIcon = 'question-lg'; + 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); + } + }); + } + + protected removeRoleAssignment(id: string): void { + this.roleAssignmentsService.deleteRoleAssignment({ scopeId: this.target.rbacScopeId, roleAssignmentId: id }).subscribe({ + next: () => { + this.roleAssignmentCount = 0; + + for (const key of Object.keys(this.roleAssignments)) { + const filtered = this.roleAssignments[key].filter((x) => x.id !== id); + this.roleAssignmentCount += filtered.length; + + if (filtered.length > 0) { + this.roleAssignments[key] = filtered; + } else { + delete this.roleAssignments[key]; + } + } + + this.notificationService.showNotification( + 'success', + 'Portal.RbacManagement.SuccessToast.Title', + 'Portal.RbacManagement.SuccessToast.Message' + ); + }, + error: (error) => { + this.errorSubject$.next(error); + } + }); + } + + protected canDeleteAssignment(assignment: RoleAssignmentDto): boolean { + if (assignment.isInherited) { + return false; + } + + if (assignment.role === Role.Owner && this.targetIsOrganization && this.roleAssignments[Role.Owner].length === 1) { + // This is a special case which forbids deleting the only Owner assignment from an organization. + return false; + } + + return true; + } + + protected navigateToScope(scopeId: string, scopeName: string): void { + this.setTarget({ + rbacScopeId: scopeId, + name: scopeName + }); + } +} diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.html b/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.html new file mode 100644 index 00000000..93545775 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.html @@ -0,0 +1,14 @@ +@if (buttonOnly) { + +} @else { +
+
+ +
+ +
+} 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 new file mode 100644 index 00000000..ba95cf8c --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-widget/rbac-widget.component.ts @@ -0,0 +1,43 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap'; +import { RbacOffcanvasComponent } from '../rbac-offcanvas/rbac-offcanvas.component'; + +interface IRbacWidgetTarget { + name: string; + rbacScopeId: string; +} + +@Component({ + standalone: false, + selector: 'tp-rbac-widget', + templateUrl: './rbac-widget.component.html' +}) +export class RbacWidgetComponent { + @Input() + public translationKey: string = ''; + + @Input() + public target!: IRbacWidgetTarget; + + @Input() + public buttonOnly: boolean = false; + + @Output() + public errorOccured = new EventEmitter(); + + constructor(private readonly offcanvasService: NgbOffcanvas) {} + + protected buttonClicked(): void { + const ref = this.offcanvasService.open(RbacOffcanvasComponent, { position: 'end' }); + const component = ref.componentInstance as RbacOffcanvasComponent; + + component.error$.subscribe({ + next: (value) => { + ref.close(); + this.errorOccured.emit(value); + } + }); + + component.setTarget(this.target); + } +} diff --git a/src/Turnierplan.App/Client/src/app/portal/components/tournament-explorer/tournament-explorer.component.html b/src/Turnierplan.App/Client/src/app/portal/components/tournament-explorer/tournament-explorer.component.html index e4557c61..13d4aaba 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/tournament-explorer/tournament-explorer.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/tournament-explorer/tournament-explorer.component.html @@ -7,7 +7,7 @@
@if (currentEntry) {
- @if (currentEntry.folderId) { + @if (currentEntry.folderId && currentEntry.folderName) {
{{ currentEntry.label }}
@if (isUpdatingFolderName) { @@ -22,7 +22,13 @@ (renamed)="renameFolder(currentEntry.folderId, $event)" /> } + + + x.folderId === folderId); if (folder) { folder.label = name; + folder.folderName = name; } this.isUpdatingFolderName = false; }, diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index 9c216460..59aca8fc 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -166,6 +166,13 @@ } } @case (3) { +
+ +
+

+
+ +
+
+
+ +
+
Handle( IAccessValidator accessValidator, IPasswordHasher secretHasher, IApiKeyRepository apiKeyRepository, + IRoleAssignmentRepository organizationRoleAssignmentRepository, IMapper mapper, CancellationToken cancellationToken) { @@ -62,8 +63,9 @@ private static async Task Handle( await apiKeyRepository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); // Once the api key has an ID, the role assignment can be created - organization.AddRoleAssignment(Role.Reader, apiKey.AsPrincipal()); + var roleAssignment = organization.AddRoleAssignment(Role.Reader, apiKey.AsPrincipal()); + await organizationRoleAssignmentRepository.CreateAsync(roleAssignment).ConfigureAwait(false); await apiKeyRepository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); transaction.ShouldCommit = true; diff --git a/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs index c491bf89..dcc03da9 100644 --- a/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs @@ -19,7 +19,8 @@ protected override RoleAssignmentDto Map(IMapper mapper, MappingContext context, return new RoleAssignmentDto { Id = source.Id, - Scope = source.Scope.GetScopeId(), + ScopeId = source.Scope.GetScopeId(), + ScopeName = source.Scope.Name, CreatedAt = source.CreatedAt, Role = source.Role, Principal = new PrincipalDto diff --git a/src/Turnierplan.App/Models/RoleAssignmentDto.cs b/src/Turnierplan.App/Models/RoleAssignmentDto.cs index a1bcffe0..a39eda12 100644 --- a/src/Turnierplan.App/Models/RoleAssignmentDto.cs +++ b/src/Turnierplan.App/Models/RoleAssignmentDto.cs @@ -6,7 +6,9 @@ public sealed record RoleAssignmentDto { public required Guid Id { get; init; } - public required string Scope { get; init; } + public required string ScopeId { get; init; } + + public required string ScopeName { get; init; } public required DateTime CreatedAt { get; init; } diff --git a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs index 50fe072e..e79a62f7 100644 --- a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs +++ b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs @@ -5,6 +5,8 @@ namespace Turnierplan.Core.SeedWork; public interface IEntityWithRoleAssignments : IEntityWithPublicId where T : Entity, IEntityWithRoleAssignments { + string Name { get; } + IReadOnlyList> RoleAssignments { get; } RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null);