Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/Turnierplan.App.Test.Functional/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ public static class Identity
public static string Login() => "/api/identity/login";
}

public static class Organizations
{
public static string Create() => "/api/organizations";
}

public static class Users
{
public static string List() => "/api/users";
public static string Create() => "/api/users";
public static string Delete(Guid id) => $"/api/users/{id}";
public static string Update(Guid id) => $"/api/users/{id}";
}
}
42 changes: 42 additions & 0 deletions src/Turnierplan.App.Test.Functional/Scenarios.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using FluentAssertions.Extensions;
using Turnierplan.App.Models;
using Turnierplan.Core.ApiKey;
using Turnierplan.Core.Extensions;
using Turnierplan.Core.Organization;
Expand Down Expand Up @@ -55,4 +58,43 @@ public async Task When_ApiKey_And_User_Are_Deleted_The_Role_Assignments_Are_Also
_testServer.ExecuteContextAction(db => db.OrganizationRoleAssignments.Count()).Should().Be(0);
_testServer.ExecuteContextAction(db => db.TournamentRoleAssignments.Count()).Should().Be(0);
}

[Fact]
public async Task New_User_Can_Not_Create_Organization_Unless_Explicitly_Granted_Permission()
{
const string newUserName = "test_user";
const string newUserPassword = "test123";

var resp = await _testServer.Client.PostAsJsonAsync(
Routes.Users.Create(),
new { UserName = newUserName, Password = newUserPassword },
TestContext.Current.CancellationToken);
resp.EnsureSuccessStatusCode();

var userClient = _testServer.CreateNewClientAndLogIn(newUserName, newUserPassword);
resp = await userClient.PostAsJsonAsync(
Routes.Organizations.Create(),
new { Name = "test_org" },
TestContext.Current.CancellationToken);
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);

// extra step required to get ID of new user
resp = await _testServer.Client.GetAsync(Routes.Users.List(), TestContext.Current.CancellationToken);
resp.EnsureSuccessStatusCode();
var allUsers = await resp.Content.ReadFromJsonAsync<UserDto[]>(TestContext.Current.CancellationToken);
var newUserId = allUsers!.Single(x => x.UserName.Equals(newUserName)).Id;

resp = await _testServer.Client.PutAsJsonAsync(
Routes.Users.Update(newUserId),
new { UserName = newUserName, IsAdministrator = false, AllowCreateOrganization = true, UpdatePassword = false },
TestContext.Current.CancellationToken);
resp.EnsureSuccessStatusCode();

userClient = _testServer.CreateNewClientAndLogIn(newUserName, newUserPassword);
resp = await userClient.PostAsJsonAsync(
Routes.Organizations.Create(),
new { Name = "test_org" },
TestContext.Current.CancellationToken);
resp.EnsureSuccessStatusCode();
}
}
21 changes: 13 additions & 8 deletions src/Turnierplan.App.Test.Functional/TestServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,30 +33,35 @@ public TestServer()
{
var ctx = scope.ServiceProvider.GetRequiredService<TurnierplanContext>();

var user = new User(username)
{
IsAdministrator = true
};
var user = new User(username);

user.SetIsAdministrator(true);
user.UpdatePassword(scope.ServiceProvider.GetRequiredService<IPasswordHasher<User>>().HashPassword(user, password));

ctx.Users.Add(user);
ctx.SaveChanges();
}

Client = CreateNewClientAndLogIn(username, password);
}

public HttpClient Client { get; }

public HttpClient CreateNewClientAndLogIn(string username, string password)
{
var loginRequest = new HttpRequestMessage(HttpMethod.Post, Routes.Identity.Login())
{
Content = JsonContent.Create(new { UserName = username, Password = password})
};

Client = _application.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = true });
var loginResponseTask = Client.SendAsync(loginRequest);
var client = _application.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = true });
var loginResponseTask = client.SendAsync(loginRequest);
loginResponseTask.Wait();
var loginResponse = loginResponseTask.Result;
loginResponse.EnsureSuccessStatusCode();
}

public HttpClient Client { get; }
return client;
}

public void ExecuteContextAction(Action<TurnierplanContext> action)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
userName: string;
fullName: string;
adm?: string;
createorg?: string;
uid: string;
}

Expand All @@ -32,6 +33,7 @@
private static readonly localStorageUserFullNameKey = 'tp_id_userFullName';
private static readonly localStorageUserEMailKey = 'tp_id_userEMail';
private static readonly localStorageUserAdministratorKey = 'tp_id_userAdmin';
private static readonly localStorageUserAllowCreateOrganizationKey = 'tp_id_userAllowCreateOrg';
private static readonly localStorageAccessTokenExpiryKey = 'tp_id_accTokenExp';
private static readonly localStorageRefreshTokenExpiryKey = 'tp_id_rfsTokenExp';

Expand All @@ -41,6 +43,7 @@
AuthenticationService.localStorageUserFullNameKey,
AuthenticationService.localStorageUserEMailKey,
AuthenticationService.localStorageUserAdministratorKey,
AuthenticationService.localStorageUserAllowCreateOrganizationKey,
AuthenticationService.localStorageAccessTokenExpiryKey,
AuthenticationService.localStorageRefreshTokenExpiryKey
];
Expand Down Expand Up @@ -93,6 +96,7 @@
decodedAccessToken.fullName,
decodedAccessToken.mail,
decodedAccessToken.adm === 'true',
decodedAccessToken.createorg === 'true',
decodedAccessToken.exp,
decodedRefreshToken.exp
);
Expand Down Expand Up @@ -146,6 +150,12 @@
return this.authentication$.pipe(map(() => localStorage.getItem(AuthenticationService.localStorageUserAdministratorKey) === 'true'));
}

public checkIfUserIsAllowedToCreateOrganization(): Observable<boolean> {
return this.authentication$.pipe(
map(() => localStorage.getItem(AuthenticationService.localStorageUserAllowCreateOrganizationKey) === 'true')
);
}

public changePassword(
userName: string,
newPassword: string,
Expand Down Expand Up @@ -218,6 +228,7 @@
decodedAccessToken.fullName,
decodedAccessToken.mail,
decodedAccessToken.adm === 'true',
decodedAccessToken.createorg === 'true',
decodedAccessToken.exp,
decodedRefreshToken.exp
);
Expand Down Expand Up @@ -297,12 +308,13 @@
return +refreshTokenExpiry;
}

private updateLocalStorageCache(

Check warning on line 311 in src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method 'updateLocalStorageCache' has too many parameters (8). Maximum allowed is 7.

See more on https://sonarcloud.io/project/issues?id=turnierplan-NET_turnierplan.NET&issues=AZzOnxNVTBjk2HgQ_Mb9&open=AZzOnxNVTBjk2HgQ_Mb9&pullRequest=360
userId: string,
userName: string,
userFullName: string | undefined,
userEMail: string | undefined,
userIsAdmin: boolean,
userAllowCreateOrg: boolean,
accessTokenExpiry: number,
refreshTokenExpiry: number
): void {
Expand All @@ -311,6 +323,7 @@
localStorage.setItem(AuthenticationService.localStorageUserFullNameKey, userFullName ?? '');
localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail ?? '');
localStorage.setItem(AuthenticationService.localStorageUserAdministratorKey, `${userIsAdmin}`);
localStorage.setItem(AuthenticationService.localStorageUserAllowCreateOrganizationKey, `${userAllowCreateOrg}`);

localStorage.setItem(AuthenticationService.localStorageAccessTokenExpiryKey, `${accessTokenExpiry}`);
localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`);
Expand Down
11 changes: 8 additions & 3 deletions src/Turnierplan.App/Client/src/app/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ export const de = {
Badges: {
OrganizationCount: 'Organisationen'
},
NoOrganizations:
'Sie sind keinen Organisationen zugehörig.\nErstellen Sie eine neue Organisation, um Turniere anzulegen und zu bearbeiten',
NoOrganizations: 'Sie sind keinen Organisationen zugehörig.',
CreateNewOrganization: 'Erstellen Sie eine neue Organisation, um Turniere anzulegen und zu bearbeiten',
NewOrganization: 'Neue Organisation',
OrganizationTile: {
Open: 'öffnen'
Expand All @@ -109,6 +109,8 @@ export const de = {
EMail: 'E-Mail',
CreatedAt: 'Erstellt am',
LastPasswordChange: 'Letzte Passwortänderung',
AllowCreateOrganization: 'Org. Erstellen',
AllowCreateOrganizationTooltip: 'Legt fest, ob dieser Benutzer neue Organisationen erstellen darf.',
Administrator: 'Admin'
},
EditUser: {
Expand All @@ -119,6 +121,7 @@ export const de = {
FullName: 'Name:',
Email: 'E-Mailadresse',
EmailInvalid: 'Die eingegebene E-Mailadresse ist ungültig.',
AllowCreateOrganization: 'Benutzer darf neue Organisationen anlegen',
IsAdministrator: 'Administrator',
AdministratorWarning: 'Mit Administratorrechten kann dieser Nutzer ALLES machen!',
UpdatePassword: 'Passwort ändern',
Expand Down Expand Up @@ -152,6 +155,8 @@ export const de = {
PasswordInvalid: 'Das eingegebene Passwort ist ungültig.'
},
UserNotice: 'Der erstellte Nutzer kann sich unmittelbar danach mit Benutzername und Passwort anmelden.',
AllowCreateOrganizationNotice:
'Neue Benutzer können standardmäßig keine Organisationen anlegen. Dies kann in der "Benutzer bearbeiten"-Ansicht konfiguriert werden.',
Submit: 'Erstellen'
},
CreateOrganization: {
Expand All @@ -162,7 +167,7 @@ export const de = {
NameInvalid: 'Der Name einer neuen Organisation darf nicht leer sein.',
NameValid: 'Dieser Name kann verwendet werden.'
},
UserNotice: 'Eine Organisation ist z.B. Ihr Sportverein oder Ihre Firma.',
OrganizationExamples: 'Eine Organisation ist z.B. Ihr Sportverein oder Ihre Firma.',
Submit: 'Erstellen'
},
ViewOrganization: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Directive, ElementRef, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';

import { AuthenticationService } from '../../core/services/authentication.service';

@Directive({ selector: '[tpAllowCreateOrganization]' })
export class AllowCreateOrganizationDirective implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>();

constructor(
private readonly templateRef: TemplateRef<ElementRef>,
private readonly viewContainer: ViewContainerRef,
private readonly authenticationService: AuthenticationService
) {}

public ngOnInit(): void {
this.authenticationService
.checkIfUserIsAllowedToCreateOrganization()
.pipe(takeUntil(this.destroyed$))
.subscribe({
next: (allowCreateOrganization) => {
this.viewContainer.clear();
if (allowCreateOrganization) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
}
});
}

public ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@
<table class="table" [attr.aria-label]="'Portal.Administration.Users.TableLabel' | translate">
<thead>
<tr>
<th translate="Portal.Administration.Users.UserName"></th>
<th translate="Portal.Administration.Users.FullName"></th>
<th translate="Portal.Administration.Users.EMail"></th>
<th translate="Portal.Administration.Users.CreatedAt"></th>
<th translate="Portal.Administration.Users.LastPasswordChange"></th>
<th translate="Portal.Administration.Users.Administrator"></th>
<th class="text-nowrap" translate="Portal.Administration.Users.UserName"></th>
<th class="text-nowrap" translate="Portal.Administration.Users.FullName"></th>
<th class="text-nowrap" translate="Portal.Administration.Users.EMail"></th>
<th class="text-nowrap" translate="Portal.Administration.Users.CreatedAt"></th>
<th class="text-nowrap" translate="Portal.Administration.Users.LastPasswordChange"></th>
<th class="text-nowrap">
<span translate="Portal.Administration.Users.AllowCreateOrganization"></span>
<tp-tooltip-icon
[icon]="'question-circle'"
[tooltipText]="'Portal.Administration.Users.AllowCreateOrganizationTooltip'" />
</th>
<th class="text-nowrap" translate="Portal.Administration.Users.Administrator"></th>
<th></th>
</tr>
</thead>
Expand All @@ -35,13 +41,18 @@
<td class="align-middle">{{ user.eMail ?? '' }}</td>
<td class="align-middle small">{{ user.createdAt | translateDate: 'medium' }}</td>
<td class="align-middle small">{{ user.lastPasswordChange | translateDate: 'medium' }}</td>
<td class="align-middle">
@if (user.allowCreateOrganization) {
<i class="bi bi-check-circle"></i>
}
</td>
<td class="align-middle">
@if (user.isAdministrator) {
<i class="bi bi-check-circle"></i>
}
</td>
<td>
<div class="d-flex flex-row gap-2">
<td class="align-middle">
<div class="d-flex flex-row gap-1">
<tp-action-button
[icon]="'pencil'"
[type]="'outline-secondary'"
Expand Down Expand Up @@ -124,6 +135,16 @@
[text]="'Portal.Administration.EditUser.AdministratorWarning'" />
}

@if (!isAdministratorControl.value) {
<div class="form-check mb-3">
<input class="form-check-input" id="allowCreateOrganization" type="checkbox" formControlName="allowCreateOrganization" />
<label
class="form-check-label"
for="allowCreateOrganization"
translate="Portal.Administration.EditUser.AllowCreateOrganization"></label>

Check warning on line 144 in src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A form label must be associated with a control and have accessible text.

See more on https://sonarcloud.io/project/issues?id=turnierplan-NET_turnierplan.NET&issues=AZzOnxM0TBjk2HgQ_Mb8&open=AZzOnxM0TBjk2HgQ_Mb8&pullRequest=360
</div>
}

<div class="form-check mb-3">
<input
class="form-check-input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { updateUser } from '../../../api/fn/users/update-user';
import { deleteUser } from '../../../api/fn/users/delete-user';
import { DeleteOffcanvasComponent } from '../../components/delete-offcanvas/delete-offcanvas.component';
import { OffcanvasWrapperComponent } from '../../components/offcanvas-wrapper/offcanvas-wrapper.component';
import { TooltipIconComponent } from '../../components/tooltip-icon/tooltip-icon.component';

@Component({
templateUrl: './administration-page.component.html',
Expand All @@ -39,7 +40,8 @@ import { OffcanvasWrapperComponent } from '../../components/offcanvas-wrapper/of
ReactiveFormsModule,
AlertComponent,
DeleteOffcanvasComponent,
OffcanvasWrapperComponent
OffcanvasWrapperComponent,
TooltipIconComponent
]
})
export class AdministrationPageComponent implements OnInit {
Expand All @@ -57,6 +59,7 @@ export class AdministrationPageComponent implements OnInit {
fullName: new FormControl('', { nonNullable: true }),
eMail: new FormControl('', { nonNullable: true, validators: [Validators.email] }),
isAdministrator: new FormControl(false, { nonNullable: true }),
allowCreateOrganization: new FormControl(false, { nonNullable: true }),
updatePassword: new FormControl(false, { nonNullable: true }),
password: new FormControl('', { nonNullable: true, validators: [Validators.required] })
});
Expand Down Expand Up @@ -105,6 +108,7 @@ export class AdministrationPageComponent implements OnInit {
fullName: this.userSelectedForEditing.fullName ?? '',
eMail: this.userSelectedForEditing.eMail ?? '',
isAdministrator: this.userSelectedForEditing.isAdministrator,
allowCreateOrganization: this.userSelectedForEditing.allowCreateOrganization,
updatePassword: false,
password: ''
});
Expand Down Expand Up @@ -144,6 +148,7 @@ export class AdministrationPageComponent implements OnInit {
fullName: (formValue.fullName ?? '').trim().length > 0 ? formValue.fullName : undefined,
eMail: (formValue.eMail ?? '').trim().length > 0 ? formValue.eMail : undefined,
isAdministrator: formValue.isAdministrator,
allowCreateOrganization: formValue.isAdministrator || formValue.allowCreateOrganization,
updatePassword: formValue.updatePassword,
password: formValue.updatePassword ? formValue.password : undefined
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[ultraSlim]="true"
[title]="'Portal.CreateOrganization.LongTitle' | translate"
[backLink]="'../..'">
<label for="create_organization_name" class="form-label" translate="Portal.CreateOrganization.Form.Name"></label>

Check warning on line 6 in src/Turnierplan.App/Client/src/app/portal/pages/create-organization/create-organization.component.html

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A form label must be associated with a control and have accessible text.

See more on https://sonarcloud.io/project/issues?id=turnierplan-NET_turnierplan.NET&issues=AZzOnxMKTBjk2HgQ_Mb7&open=AZzOnxMKTBjk2HgQ_Mb7&pullRequest=360
<input
id="create_organization_name"
type="text"
Expand All @@ -20,7 +20,7 @@

<div class="form-text d-flex flex-row align-items-center">
<i class="bi bi-info-circle"></i>
<span class="ms-2" translate="Portal.CreateOrganization.UserNotice"></span>
<span class="ms-2" translate="Portal.CreateOrganization.OrganizationExamples"></span>
</div>

<hr />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<tp-page-frame *tpLoadingState="loadingState" [ultraSlim]="true" [title]="'Portal.CreateUser.LongTitle' | translate" [backLink]="'../..'">
<form [formGroup]="form">
<div class="mb-3">
<label for="create_user_name" class="form-label" translate="Portal.CreateUser.Form.UserName"></label>

Check warning on line 4 in src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A form label must be associated with a control and have accessible text.

See more on https://sonarcloud.io/project/issues?id=turnierplan-NET_turnierplan.NET&issues=AZzOnxI3TBjk2HgQ_Mb6&open=AZzOnxI3TBjk2HgQ_Mb6&pullRequest=360
<input
id="create_user_name"
type="text"
Expand Down Expand Up @@ -58,6 +58,13 @@

<hr />

<div class="form-text d-flex flex-row align-items-center text-warning-emphasis">
<i class="bi bi-exclamation-triangle"></i>
<span class="ms-2" translate="Portal.CreateUser.AllowCreateOrganizationNotice"></span>
</div>

<hr />

<div class="d-flex flex-row justify-content-end">
<tp-action-button
[type]="'success'"
Expand Down
Loading
Loading