Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/Turnierplan.App/Client/src/app/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!',
Expand Down Expand Up @@ -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!',
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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.'
}
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TournamentHeaderDto } from '../../../api';
export type FolderTreeEntry = {
id: string;
folderId?: string;
folderName?: string;
label: string;
isRoot: boolean;
indentation: number;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@if (statusCode === 404) {
@if (statusCode === 403 || statusCode === 404) {
<tp-error-page
[illustrationName]="'taken'"
[actionButton]="{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<div class="p-3">
<div class="fs-4 fw-bold mb-3" translate="Portal.RbacManagement.OffcanvasTitle"></div>
@if (isLoadingRoleAssignments) {
<div class="d-flex flex-row gap-2">
<tp-small-spinner />
<span translate="Portal.RbacManagement.Loading"></span>
</div>
} @else {
<div class="mb-4 d-flex flex-row align-items-center">
<i class="bi bi-{{ targetIcon }} me-2"></i>
<span class="fw-bold">
{{ target.name }}
</span>
<span class="flex-grow-1"></span>
<tp-action-button [title]="'Portal.RbacManagement.NewRoleAssignment'" [type]="'outline-success'" [icon]="'plus-circle'" />
</div>

<table class="mb-2 table table-bordered">
<tbody>
@for (group of roleAssignments | keyvalue; track group.key) {
<tr class="table-active">
<th>
<span [translate]="'Portal.RbacManagement.RoleName.' + group.key"></span>
<i class="ms-2 bi bi-info-circle" [ngbTooltip]="'Portal.RbacManagement.RoleDescription.' + group.key | translate"></i>
</th>
</tr>

@for (assignment of group.value; track assignment.id) {
<tr>
<td>
<div class="d-flex flex-column gap-2">
<div class="d-flex gap-2">
@switch (assignment.principal.kind) {
@case (PrincipalKind.ApiKey) {
<i class="bi bi-key" [ngbTooltip]="'Portal.RbacManagement.PrincipalKind.ApiKey' | translate"></i>
}
@case (PrincipalKind.User) {
<i class="bi bi-person" [ngbTooltip]="'Portal.RbacManagement.PrincipalKind.User' | translate"></i>
}
}
<!-- TODO: Resolve ApiKey/User name -->
<span>{{ assignment.principal.objectId }}</span>
@if (canDeleteAssignment(assignment)) {
<span class="flex-grow-1"></span>
<tp-delete-button [reducedFootprint]="true" (confirmed)="removeRoleAssignment(assignment.id)" />
}
</div>
@if (assignment.isInherited) {
<div class="text-primary d-flex gap-2">
<i class="bi bi-info-circle" [ngbTooltip]="'Portal.RbacManagement.InheritedTooltip' | translate"></i>
<span translate="Portal.RbacManagement.Inherited"></span>
<a
class="text-decoration-underline tp-cursor-pointer"
(click)="navigateToScope(assignment.scopeId, assignment.scopeName)"
>{{ assignment.scopeName }}</a
>
</div>
} @else {
<div class="text-secondary d-flex gap-2">
<i class="bi bi-info-circle"></i>
<span [translate]="scopeTranslationKey + '.NotInherited'"></span>
</div>
}
<div style="font-size: 0.7em" class="text-secondary d-flex gap-1">
<span translate="Portal.RbacManagement.Id"></span>
<span>{{ assignment.id }}</span>
<span>&middot;</span>
<span translate="Portal.RbacManagement.CreatedAt"></span>
<span>{{ assignment.createdAt | translateDate }}</span>
</div>
</div>
</td>
</tr>
}
}
</tbody>
</table>

@if (roleAssignmentCount > 1) {
<div class="small">
<span class="ms-1 me-1" translate="Portal.RbacManagement.TotalCount"></span>
<span>{{ roleAssignmentCount }}</span>
</div>
}
}
</div>
Original file line number Diff line number Diff line change
@@ -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<unknown>();
private targetIsOrganization: boolean = false;

constructor(
private readonly roleAssignmentsService: RoleAssignmentsService,
private readonly notificationService: NotificationService
) {}

public get error$(): Observable<unknown> {
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
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@if (buttonOnly) {
<tp-action-button [type]="'outline-secondary'" [icon]="'shield-lock'" [mode]="'IconOnly'" (click)="buttonClicked()" />
} @else {
<div class="fw-bold mb-3" translate="Portal.RbacManagement.Title"></div>
<div translate="{{ translationKey }}.Info"></div>

<div class="mt-3 d-flex flex-row">
<tp-action-button
[type]="'outline-secondary'"
[icon]="'shield-lock'"
[title]="'Portal.RbacManagement.ButtonLabel'"
(click)="buttonClicked()" />
</div>
}
Original file line number Diff line number Diff line change
@@ -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<unknown>();

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);
}
}
Loading
Loading