From d47daaac29536212dcb2137b6bafb00b1de3665b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 17:47:25 +0200 Subject: [PATCH 01/23] Changes to backend --- .../Identity/IdentityEndpointBase.cs | 13 +++++- .../Identity/UpdateUserDataEndpoint.cs | 36 +++------------- .../Principals/GetPrincipalNameEndpoint.cs | 2 +- .../Endpoints/Users/CreateUserEndpoint.cs | 34 ++++++++++++--- .../Endpoints/Users/UpdateUserEndpoint.cs | 23 ++++++---- .../Extensions/WebApplicationExtensions.cs | 9 ++-- .../Mapping/Rules/UserMappingRule.cs | 3 +- src/Turnierplan.App/Models/UserDto.cs | 6 ++- .../Options/TurnierplanOptions.cs | 2 - src/Turnierplan.App/Security/ClaimTypes.cs | 5 ++- src/Turnierplan.Core/User/IUserRepository.cs | 2 + src/Turnierplan.Core/User/User.cs | 42 +++++++++++++------ .../UserEntityTypeConfiguration.cs | 14 +++++-- .../Repositories/UserRepository.cs | 9 +++- 14 files changed, 123 insertions(+), 77 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs index 4abdd0ca..60648267 100644 --- a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs @@ -34,12 +34,21 @@ protected string CreateTokenForUser(User user, bool isRefreshToken) else { claims.Add(new Claim(ClaimTypes.TokenType, JwtTokenTypes.Access)); - claims.Add(new Claim(ClaimTypes.DisplayName, user.Name)); - claims.Add(new Claim(ClaimTypes.EMailAddress, user.EMail)); claims.Add(new Claim(ClaimTypes.UserId, user.Id.ToString())); + claims.Add(new Claim(ClaimTypes.UserName, user.UserName)); claims.Add(new Claim(ClaimTypes.PrincipalId, user.PrincipalId.ToString())); claims.Add(new Claim(ClaimTypes.PrincipalKind, nameof(PrincipalKind.User))); + if (!string.IsNullOrWhiteSpace(user.FullName)) + { + claims.Add(new Claim(ClaimTypes.FullName, user.FullName)); + } + + if (!string.IsNullOrWhiteSpace(user.EMail)) + { + claims.Add(new Claim(ClaimTypes.EMailAddress, user.EMail)); + } + if (user.IsAdministrator) { claims.Add(new Claim(ClaimTypes.Administrator, "true")); diff --git a/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs index 9c4b3420..956fbad5 100644 --- a/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs @@ -39,35 +39,14 @@ private async Task Handle( return Results.Unauthorized(); } - user.Name = request.UserName; - - if (!user.NormalizedEMail.Equals(User.NormalizeEmail(request.EMail))) - { - // Check if the email address is already taken - - var existingUser = await userRepository.GetByEmailAsync(request.EMail); - - if (existingUser is not null) - { - return Results.Ok(new UpdateUserDataEndpointResponse - { - Success = false - }); - } - - user.UpdateEmail(request.EMail); - } + user.FullName = request.FullName; await userRepository.UnitOfWork.SaveChangesAsync(cancellationToken); - // Give the user a new - // - access token which includes the updated username & email claims - // - refresh token because the one he currently holds is invalidated due to the updated security stamp + // Give the user a new access token which includes the updated username claim var accessToken = CreateTokenForUser(user, false); - var refreshToken = CreateTokenForUser(user, true); AddResponseCookieForToken(context, accessToken, false); - AddResponseCookieForToken(context, refreshToken, true); return Results.Ok(new UpdateUserDataEndpointResponse { @@ -77,9 +56,7 @@ private async Task Handle( public sealed record UpdateUserDataEndpointRequest { - public required string UserName { get; init; } - - public required string EMail { get; init; } + public string? FullName { get; init; } } public sealed record UpdateUserDataEndpointResponse @@ -93,12 +70,9 @@ private sealed class Validator : AbstractValidator x.UserName) - .NotEmpty(); - - RuleFor(x => x.EMail) + RuleFor(x => x.FullName) .NotEmpty() - .EmailAddress(); + .When(x => x.FullName is not null); } } } diff --git a/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs index 3caac4b6..5244094c 100644 --- a/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs @@ -29,7 +29,7 @@ private static async Task Handle( break; case PrincipalKind.User: var user = await userRepository.GetByPrincipalIdAsync(principalId); - name = user?.Name; + name = user?.UserName; break; default: return Results.BadRequest("Invalid principal kind specified."); diff --git a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs index 4142544d..ba1a629c 100644 --- a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs @@ -30,14 +30,28 @@ private static async Task Handle( return result; } - var existingUser = await repository.GetByEmailAsync(request.EMail); + if (await repository.GetByUserNameAsync(request.UserName) is not null) + { + return Results.BadRequest("The specified user name is already taken."); + } - if (existingUser is not null) + if (!string.IsNullOrWhiteSpace(request.EMail)) { - return Results.BadRequest("The specified email address is already taken."); + if (await repository.GetByEmailAsync(request.EMail) is not null) + { + return Results.BadRequest("The specified email address is already taken."); + } } - var user = new User(request.UserName, request.EMail); + var user = new User(request.UserName) + { + FullName = request.FullName + }; + + if (request.EMail is not null) + { + user.SetEmailAddress(request.EMail); + } user.UpdatePassword(passwordHasher.HashPassword(user, request.Password)); @@ -51,7 +65,9 @@ public sealed record CreateUserEndpointRequest { public required string UserName { get; init; } - public required string EMail { get; init; } + public string? FullName { get; init; } + + public string? EMail { get; init; } public required string Password { get; init; } } @@ -65,8 +81,14 @@ private Validator() RuleFor(x => x.UserName) .NotEmpty(); + RuleFor(x => x.FullName) + .NotEmpty() + .When(x => x.FullName is not null); + RuleFor(x => x.EMail) - .EmailAddress(); + .NotEmpty() + .EmailAddress() + .When(x => x.EMail is not null); RuleFor(x => x.Password) .NotEmpty(); diff --git a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs index 7139158e..f47c38e8 100644 --- a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs @@ -35,22 +35,21 @@ private static async Task Handle( return Results.NotFound(); } - if (!user.NormalizedEMail.Equals(User.NormalizeEmail(request.EMail))) + if (request.EMail is not null && !Equals(user.NormalizedEMail, User.NormalizeEmail(request.EMail))) { // If the email address ought to be changed, check that no other user uses that email address - var existingUserWithNewEmail = await repository.GetByEmailAsync(request.EMail); - - if (existingUserWithNewEmail is not null) + if (await repository.GetByEmailAsync(request.EMail) is not null) { return Results.BadRequest("The specified email address is already taken."); } } - user.Name = request.UserName; + user.UserName = request.UserName; + user.FullName = request.FullName; user.IsAdministrator = request.IsAdministrator; - user.UpdateEmail(request.EMail); + user.SetEmailAddress(request.EMail); if (request.UpdatePassword) { @@ -66,7 +65,9 @@ public sealed record UpdateUserEndpointRequest { public required string UserName { get; init; } - public required string EMail { get; init; } + public string? FullName { get; init; } + + public string? EMail { get; init; } public bool IsAdministrator { get; init; } @@ -84,8 +85,14 @@ private Validator() RuleFor(x => x.UserName) .NotEmpty(); + RuleFor(x => x.FullName) + .NotEmpty() + .When(x => x.FullName is not null); + RuleFor(x => x.EMail) - .EmailAddress(); + .NotEmpty() + .EmailAddress() + .When(x => x.EMail is not null); RuleFor(x => x.Password) .Null() diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index 6377dbdf..a2c7b5bf 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -36,13 +36,12 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) var overwriteInitialUserPassword = !string.IsNullOrWhiteSpace(options.InitialUserPassword); - var initialUserName = string.IsNullOrWhiteSpace(options.InitialUserName) ? "Administrator" : options.InitialUserName; - var initialUserEmail = string.IsNullOrWhiteSpace(options.InitialUserEmail) ? "admin@example.com" : options.InitialUserEmail; + var initialUserName = string.IsNullOrWhiteSpace(options.InitialUserName) ? "admin" : options.InitialUserName; var initialUserPassword = overwriteInitialUserPassword ? options.InitialUserPassword! : Guid.NewGuid().ToString(); var passwordHasher = scope.ServiceProvider.GetRequiredService>(); - var initialUser = new User(initialUserName, initialUserEmail) + var initialUser = new User(initialUserName) { IsAdministrator = true }; @@ -55,11 +54,11 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) if (overwriteInitialUserPassword) { // Don't log the password if it was specified using an environment variable - logger.LogInformation("An initial user \"{Name}\" was created. You can log in using \"{Email}\" and the password \"****\" (set by environment variable). This is NOT recommended in a production environment!", initialUserName, initialUserEmail); + logger.LogInformation("An initial user was created: You can log in using \"{Name}\" and the password \"****\" (set by environment variable). This is NOT recommended in a production environment!", initialUserName); } else { - logger.LogInformation("An initial user \"{Name}\" was created. You can log in using \"{Email}\" and the password \"{Password}\". IMMEDIATELY change this password when running in a production environment!", initialUserName, initialUserEmail, initialUserPassword); + logger.LogInformation("An initial user was created: You can log in using \"{Name}\" and the password \"{Password}\". IMMEDIATELY change this password when running in a production environment!", initialUserName, initialUserPassword); } } else diff --git a/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs index 120f2516..e5b3dce6 100644 --- a/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs @@ -11,7 +11,8 @@ protected override UserDto Map(IMapper mapper, MappingContext context, User sour { Id = source.Id, CreatedAt = source.CreatedAt, - Name = source.Name, + UserName = source.UserName, + FullName = source.FullName, EMail = source.EMail, LastPasswordChange = source.LastPasswordChange, IsAdministrator = source.IsAdministrator diff --git a/src/Turnierplan.App/Models/UserDto.cs b/src/Turnierplan.App/Models/UserDto.cs index caf371ee..08b503f1 100644 --- a/src/Turnierplan.App/Models/UserDto.cs +++ b/src/Turnierplan.App/Models/UserDto.cs @@ -6,9 +6,11 @@ public sealed record UserDto public required DateTime CreatedAt { get; init; } - public required string Name { get; init; } + public required string UserName { get; init; } - public required string EMail { get; init; } + public string? FullName { get; init; } + + public string? EMail { get; init; } public required DateTime LastPasswordChange { get; init; } diff --git a/src/Turnierplan.App/Options/TurnierplanOptions.cs b/src/Turnierplan.App/Options/TurnierplanOptions.cs index 2784dd8e..aac4dcb2 100644 --- a/src/Turnierplan.App/Options/TurnierplanOptions.cs +++ b/src/Turnierplan.App/Options/TurnierplanOptions.cs @@ -14,7 +14,5 @@ internal sealed record TurnierplanOptions public string? InitialUserName { get; init; } - public string? InitialUserEmail { get; init; } - public string? InitialUserPassword { get; init; } } diff --git a/src/Turnierplan.App/Security/ClaimTypes.cs b/src/Turnierplan.App/Security/ClaimTypes.cs index 64ad9898..14ac4bc2 100644 --- a/src/Turnierplan.App/Security/ClaimTypes.cs +++ b/src/Turnierplan.App/Security/ClaimTypes.cs @@ -3,11 +3,12 @@ namespace Turnierplan.App.Security; internal static class ClaimTypes { public const string Administrator = "adm"; - public const string DisplayName = "name"; - public const string EMailAddress = "mail"; + public const string EMailAddress = "mail"; // TODO Check dependent usages + public const string FullName = "fullName"; // TODO Check dependent usages public const string PrincipalKind = "principalkind"; public const string PrincipalId = "principalid"; public const string SecurityStamp = "sst"; public const string TokenType = "typ"; public const string UserId = "uid"; + public const string UserName = "userName"; // TODO Check dependent usages } diff --git a/src/Turnierplan.Core/User/IUserRepository.cs b/src/Turnierplan.Core/User/IUserRepository.cs index e3aaa86c..543ee614 100644 --- a/src/Turnierplan.Core/User/IUserRepository.cs +++ b/src/Turnierplan.Core/User/IUserRepository.cs @@ -10,5 +10,7 @@ public interface IUserRepository : IRepository Task GetByPrincipalIdAsync(Guid id); + Task GetByUserNameAsync(string userName); + Task GetByEmailAsync(string email); } diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index 2a94a13d..62e663e9 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -2,30 +2,33 @@ namespace Turnierplan.Core.User; +// TODO: change default admin user behavior to use userName instead of email + public sealed class User : Entity { - public User(string name, string email) + public User(string userName) { - email = email.Trim(); Id = Guid.NewGuid(); PrincipalId = Guid.NewGuid(); CreatedAt = DateTime.UtcNow; - Name = name; - EMail = email; - NormalizedEMail = NormalizeEmail(email); + UserName = userName; + FullName = null; + EMail = null; + NormalizedEMail = null; PasswordHash = string.Empty; IsAdministrator = false; LastPasswordChange = DateTime.MinValue; SecurityStamp = Guid.Empty; } - internal User(Guid id, Guid principalId, DateTime createdAt, string name, string eMail, string normalizedEMail, string passwordHash, bool isAdministrator, DateTime lastPasswordChange, Guid securityStamp) + internal User(Guid id, Guid principalId, DateTime createdAt, string userName, string? fullName, string? eMail, string? normalizedEMail, string passwordHash, bool isAdministrator, DateTime lastPasswordChange, Guid securityStamp) { Id = id; PrincipalId = principalId; CreatedAt = createdAt; - Name = name; + UserName = userName; + FullName = fullName; EMail = eMail; NormalizedEMail = normalizedEMail; PasswordHash = passwordHash; @@ -40,11 +43,13 @@ internal User(Guid id, Guid principalId, DateTime createdAt, string name, string public DateTime CreatedAt { get; } - public string Name { get; set; } + public string UserName { get; set; } + + public string? FullName { get; set; } - public string EMail { get; private set; } + public string? EMail { get; private set; } - public string NormalizedEMail { get; private set; } + public string? NormalizedEMail { get; private set; } public string PasswordHash { get; private set; } @@ -68,10 +73,21 @@ public void UpdatePassword(string passwordHash) SecurityStamp = Guid.NewGuid(); } - public void UpdateEmail(string newEmail) + public void SetEmailAddress(string? newEmail) { - EMail = newEmail; - NormalizedEMail = NormalizeEmail(newEmail); + if (newEmail is null) + { + EMail = null; + NormalizedEMail = null; + } + else + { + ArgumentException.ThrowIfNullOrWhiteSpace(newEmail); + + EMail = newEmail.Trim(); + NormalizedEMail = NormalizeEmail(newEmail); + } + SecurityStamp = Guid.NewGuid(); } diff --git a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs index 68595986..ca085ae5 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs @@ -24,14 +24,22 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.CreatedAt) .IsRequired(); - builder.Property(x => x.Name) + // TODO: Add migration + + builder.Property(x => x.UserName) .IsRequired(); + builder.HasIndex(x => x.UserName) + .IsUnique(); + + builder.Property(x => x.FullName) + .IsRequired(false); + builder.Property(x => x.EMail) - .IsRequired(); + .IsRequired(false); builder.Property(x => x.NormalizedEMail) - .IsRequired(); + .IsRequired(false); builder.HasIndex(x => x.NormalizedEMail) .IsUnique(); diff --git a/src/Turnierplan.Dal/Repositories/UserRepository.cs b/src/Turnierplan.Dal/Repositories/UserRepository.cs index 3a0d2e9b..e7fe1e36 100644 --- a/src/Turnierplan.Dal/Repositories/UserRepository.cs +++ b/src/Turnierplan.Dal/Repositories/UserRepository.cs @@ -21,12 +21,19 @@ public Task> GetAllUsersAsync() return DbSet.Where(x => x.PrincipalId == id).FirstOrDefaultAsync(); } + public Task GetByUserNameAsync(string userName) + { + return DbSet + .Where(x => x.UserName.Equals(userName)) + .FirstOrDefaultAsync(); + } + public Task GetByEmailAsync(string email) { var normalizedEMail = User.NormalizeEmail(email); return DbSet - .Where(x => x.NormalizedEMail.Equals(normalizedEMail)) + .Where(x => x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedEMail)) .FirstOrDefaultAsync(); } } From 2163fc95e0783dcbd1476a3a23b8ad385f59041d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 17:49:20 +0200 Subject: [PATCH 02/23] Update readme accordingly --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e4df759a..9a3d21d5 100644 --- a/README.md +++ b/README.md @@ -178,9 +178,9 @@ To run the application from source, follow these steps: 1. Open the `src/Turnierplan.sln` solution and navigate to the docker compose file located under `Solution Items`. Run the `turnierplan.database` docker compose service. This will start up the PostgreSQL database for local development. 2. Navigate to the `Turnierplan.App` project and run the `Turnierplan.App` launch configuration. This will start the backend using port `45000`. 3. Open a terminal and navigate to the `src/Turnierplan.App/Client` directory. Run `npm install` to install the node dependencies. Next, you can start the client application by typing `npm run start`. Note that this will only work if the backend application has previously been run because the client app startup depends on OpenAPI files generated by the backend build process. -4. Access the client application using [http://localhost:45001](http://localhost:45001) and log in using default credentials. The email address is `admin@example.com` and the password is `P@ssw0rd`. +4. Access the client application using [http://localhost:45001](http://localhost:45001) and log in using default credentials. The user name is `admin` and the password is `P@ssw0rd`. -When running locally, the API documentation can be viewed by opening [http://localhost:45000/scalar]( http://localhost:45000/scalar). +When running locally, the API documentation can be viewed by opening [http://localhost:45000/scalar](http://localhost:45000/scalar). ## Screenshots From 9f29c7e6fd8f92b2e686e16eddc4148e87be26ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 18:07:19 +0200 Subject: [PATCH 03/23] Add migration --- .../20250922154958_Add_UserName.Designer.cs | 1735 +++++++++++++++++ .../Migrations/20250922154958_Add_UserName.cs | 151 ++ .../TurnierplanContextModelSnapshot.cs | 18 +- 3 files changed, 1897 insertions(+), 7 deletions(-) create mode 100644 src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.Designer.cs create mode 100644 src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.cs diff --git a/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.Designer.cs b/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.Designer.cs new file mode 100644 index 00000000..bed1d8a5 --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.Designer.cs @@ -0,0 +1,1735 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Turnierplan.Dal; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + [DbContext(typeof(TurnierplanContext))] + [Migration("20250922154958_Add_UserName")] + partial class Add_UserName + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecretHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("ApiKeys", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.HasIndex("Timestamp"); + + b.ToTable("ApiKeyRequests", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GenerationCount") + .HasColumnType("integer"); + + b.Property("LastGeneration") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("TournamentId"); + + b.ToTable("Documents", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Folders", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("ResourceIdentifier") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("ResourceIdentifier") + .IsUnique(); + + b.ToTable("Images", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Organizations", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("SourceLinkId") + .HasColumnType("bigint"); + + b.Property("Tag") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("SourceLinkId"); + + b.ToTable("Applications", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationId") + .HasColumnType("bigint"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("ClassId"); + + b.ToTable("ApplicationTeams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ColorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactPerson") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.ToTable("InvitationLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowNewRegistrations") + .HasColumnType("boolean"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("InvitationLinkId") + .HasColumnType("bigint"); + + b.Property("MaxTeamsPerRegistration") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("InvitationLinkId"); + + b.ToTable("InvitationLinkEntries", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("PlanningRealms", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationTeamId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("TeamTournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationTeamId") + .IsUnique(); + + b.HasIndex("TeamTournamentId", "TeamId") + .IsUnique(); + + b.ToTable("TeamLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("TournamentClasses", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.ToTable("IAM_ApiKey", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.ToTable("IAM_Folder", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.ToTable("IAM_Image", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("IAM_Organization", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("IAM_PlanningRealm", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TournamentId"); + + b.ToTable("IAM_Tournament", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VenueId"); + + b.ToTable("IAM_Venue", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AlphabeticalId") + .HasColumnType("character(1)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Groups", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.HasKey("TournamentId", "GroupId", "TeamId"); + + b.HasIndex("TournamentId", "TeamId"); + + b.ToTable("GroupParticipants", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Court") + .HasColumnType("smallint"); + + b.Property("FinalsRound") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("IsCurrentlyPlaying") + .HasColumnType("boolean"); + + b.Property("Kickoff") + .HasColumnType("timestamp with time zone"); + + b.Property("OutcomeType") + .HasColumnType("integer"); + + b.Property("PlayoffPosition") + .HasColumnType("integer"); + + b.Property("ScoreA") + .HasColumnType("integer"); + + b.Property("ScoreB") + .HasColumnType("integer"); + + b.Property("TeamSelectorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamSelectorB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.HasIndex("TournamentId", "GroupId"); + + b.ToTable("Matches", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("EntryFeePaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutOfCompetition") + .HasColumnType("boolean"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Teams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BannerImageId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("PublicPageViews") + .HasColumnType("integer"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BannerImageId"); + + b.HasIndex("FolderId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.HasIndex("VenueId"); + + b.ToTable("Tournaments", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .HasColumnType("text"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("IsAdministrator") + .HasColumnType("boolean"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChange") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEMail") + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEMail") + .IsUnique(); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection>("AddressDetails") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("ExternalLinks") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Venues", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "ApiKey") + .WithMany("Requests") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiKey"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Documents") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Folders") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Images") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("Applications") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", "SourceLink") + .WithMany() + .HasForeignKey("SourceLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PlanningRealm"); + + b.Navigation("SourceLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.Application", "Application") + .WithMany("Teams") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("InvitationLinks") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsMany("Turnierplan.Core.PlanningRealm.InvitationLink+ExternalLink", "ExternalLinks", b1 => + { + b1.Property("InvitationLinkId") + .HasColumnType("bigint"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("InvitationLinkId", "__synthesizedOrdinal"); + + b1.ToTable("InvitationLinks", "turnierplan"); + + b1.ToJson("ExternalLinks"); + + b1.WithOwner() + .HasForeignKey("InvitationLinkId"); + }); + + b.Navigation("ExternalLinks"); + + b.Navigation("PlanningRealm"); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", null) + .WithMany("Entries") + .HasForeignKey("InvitationLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("PlanningRealms") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.ApplicationTeam", "ApplicationTeam") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "ApplicationTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "TeamTournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationTeam"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", null) + .WithMany("TournamentClasses") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Folder.Folder", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Venue.Venue", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Groups") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany("Participants") + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithMany() + .HasForeignKey("TournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Matches") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany() + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Teams") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "BannerImage") + .WithMany() + .HasForeignKey("BannerImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Folder.Folder", "Folder") + .WithMany("Tournaments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Tournaments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Venue.Venue", "Venue") + .WithMany("Tournaments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("Turnierplan.Core.Tournament.ComputationConfiguration", "ComputationConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.PrimitiveCollection("ComparisonModes") + .IsRequired() + .HasColumnType("integer[]") + .HasAnnotation("Relational:JsonPropertyName", "cmp"); + + b1.Property("HigherScoreLoses") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "r"); + + b1.Property("MatchDrawnPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "d"); + + b1.Property("MatchLostPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "l"); + + b1.Property("MatchWonPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "w"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("ComputationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.MatchPlanConfiguration", "MatchPlanConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("MatchPlanConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.FinalsRoundConfig", "FinalsRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("EnableThirdPlacePlayoff") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "3rd"); + + b2.Property("FirstFinalsRoundOrder") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "fo"); + + b2.PrimitiveCollection>("TeamSelectors") + .HasColumnType("text[]") + .HasAnnotation("Relational:JsonPropertyName", "ts"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "fr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + + b2.OwnsMany("Turnierplan.Core.Tournament.AdditionalPlayoffConfig", "AdditionalPlayoffs", b3 => + { + b3.Property("FinalsRoundConfigMatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b3.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b3.Property("PlayoffPosition") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "p"); + + b3.Property("TeamSelectorA") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "a"); + + b3.Property("TeamSelectorB") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "b"); + + b3.HasKey("FinalsRoundConfigMatchPlanConfigurationTournamentId", "__synthesizedOrdinal"); + + b3.ToTable("Tournaments", "turnierplan"); + + b3.HasAnnotation("Relational:JsonPropertyName", "ap"); + + b3.WithOwner() + .HasForeignKey("FinalsRoundConfigMatchPlanConfigurationTournamentId"); + }); + + b2.Navigation("AdditionalPlayoffs"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.GroupRoundConfig", "GroupRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("GroupMatchOrder") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "o"); + + b2.Property("GroupPhaseRounds") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "r"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "gr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.ScheduleConfig", "ScheduleConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("FinalsPhaseNumberOfCourts") + .HasColumnType("smallint") + .HasAnnotation("Relational:JsonPropertyName", "fc"); + + b2.Property("FinalsPhasePauseTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "fp"); + + b2.Property("FinalsPhasePlayTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "fd"); + + b2.Property("FirstMatchKickoff") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "f"); + + b2.Property("GroupPhaseNumberOfCourts") + .HasColumnType("smallint") + .HasAnnotation("Relational:JsonPropertyName", "gc"); + + b2.Property("GroupPhasePauseTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "gp"); + + b2.Property("GroupPhasePlayTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "gd"); + + b2.Property("PauseBetweenGroupAndFinalsPhase") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "p"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "sc"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.Navigation("FinalsRoundConfig"); + + b1.Navigation("GroupRoundConfig"); + + b1.Navigation("ScheduleConfig"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration", "PresentationConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.Property("ShowPrimaryLogo") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "ol"); + + b1.Property("ShowResults") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "o"); + + b1.Property("ShowSecondaryLogo") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "sl"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("PresentationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header1", b2 => + { + b2.Property("PresentationConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("Content") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "c"); + + b2.Property("CustomContent") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "h1"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header2", b2 => + { + b2.Property("PresentationConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("Content") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "c"); + + b2.Property("CustomContent") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "h2"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.Navigation("Header1") + .IsRequired(); + + b1.Navigation("Header2") + .IsRequired(); + }); + + b.Navigation("BannerImage"); + + b.Navigation("ComputationConfiguration") + .IsRequired(); + + b.Navigation("Folder"); + + b.Navigation("MatchPlanConfiguration"); + + b.Navigation("Organization"); + + b.Navigation("PresentationConfiguration") + .IsRequired(); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + + b.Navigation("Venue"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Venues") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Navigation("Requests"); + + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Folders"); + + b.Navigation("Images"); + + b.Navigation("PlanningRealms"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + + b.Navigation("Venues"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Navigation("Applications"); + + b.Navigation("InvitationLinks"); + + b.Navigation("RoleAssignments"); + + b.Navigation("TournamentClasses"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Navigation("Documents"); + + b.Navigation("Groups"); + + b.Navigation("Matches"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.cs b/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.cs new file mode 100644 index 00000000..bd43f714 --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.cs @@ -0,0 +1,151 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + /// + public partial class Add_UserName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Note: This migration was modified manually! + + // pre-migration: Name & EMail are both required columns + // post-migration: Only UserName is required column, FullName and EMail are optional + + // migration steps: + // 1. Create new required "UserName" column with a placeholder default value + // 2. Update all users and set the "UserName" to the current value of the "EMail" column + // 3. Rename the previous "Name" column to "FullName" and make that column optional + // 4. Make the "EMail" and "NormalizedEMail" columns optional + // 5. Create the unique index on the "UserName" column + + // step 1: + migrationBuilder.AddColumn( + name: "UserName", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "x"); + + // step 2: + migrationBuilder.Sql(""" +UPDATE turnierplan."Users" SET "UserName" = "EMail"; +"""); + + // step 3: + migrationBuilder.RenameColumn( + name: "Name", + schema: "turnierplan", + table: "Users", + newName: "FullName"); + + migrationBuilder.AlterColumn( + name: "FullName", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + // step 4: + migrationBuilder.AlterColumn( + name: "EMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "NormalizedEMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + // step 5: + migrationBuilder.CreateIndex( + name: "IX_Users_UserName", + schema: "turnierplan", + table: "Users", + column: "UserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Note: This migration was modified manually! + + // pre-migration: Only UserName is required column, FullName and EMail are optional + // post-migration: Name & EMail are both required columns + + // migration steps: + // 1. Delete the unique index on the "UserName" column + // 2. Make the "EMail" and "NormalizedEMail" columns required + // 3. Rename the previous "FullName" column to "Name" and make that column required + // 4. Delete the "UserName" column + + // step 1: + migrationBuilder.DropIndex( + name: "IX_Users_UserName", + schema: "turnierplan", + table: "Users"); + + // step 2: + migrationBuilder.AlterColumn( + name: "EMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "NormalizedEMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + // step 3: + migrationBuilder.RenameColumn( + name: "FullName", + schema: "turnierplan", + table: "Users", + newName: "Name"); + + migrationBuilder.AlterColumn( + name: "Name", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + // step 4: + migrationBuilder.DropColumn( + name: "UserName", + schema: "turnierplan", + table: "Users"); + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs index a46184bc..a61243ec 100644 --- a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs +++ b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("ProductVersion", "9.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -880,7 +880,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("EMail") - .IsRequired() + .HasColumnType("text"); + + b.Property("FullName") .HasColumnType("text"); b.Property("IsAdministrator") @@ -892,12 +894,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastPasswordChange") .HasColumnType("timestamp with time zone"); - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - b.Property("NormalizedEMail") - .IsRequired() .HasColumnType("text"); b.Property("PasswordHash") @@ -910,6 +907,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SecurityStamp") .HasColumnType("uuid"); + b.Property("UserName") + .IsRequired() + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("NormalizedEMail") @@ -918,6 +919,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PrincipalId") .IsUnique(); + b.HasIndex("UserName") + .IsUnique(); + b.ToTable("Users", "turnierplan"); }); From ffb2583aa876ef0bc410343b8a4dad1296217f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 19:10:25 +0200 Subject: [PATCH 04/23] u ghjgf --- .../administration-page.component.html | 10 ++++++---- .../administration-page.component.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html index dda94487..00a636fb 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html @@ -17,7 +17,8 @@ - + + @@ -30,8 +31,9 @@ @let isCurrentUser = user.id === currentUserId; {{ user.id }} - {{ user.name }} - {{ user.eMail }} + {{ user.userName }} + {{ user.fullName ?? '' }} + {{ user.eMail ?? '' }} {{ user.createdAt | translateDate: 'medium' }} {{ user.lastPasswordChange | translateDate: 'medium' }} @@ -154,7 +156,7 @@

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 12f4e350..4e5c4792 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 @@ -53,7 +53,8 @@ export class AdministrationPageComponent implements OnInit { protected editUserForm = new FormGroup({ userName: new FormControl('', { nonNullable: true, validators: [Validators.required] }), - eMail: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), + fullName: new FormControl('', { nonNullable: false }), + eMail: new FormControl('', { nonNullable: false, validators: [Validators.email] }), isAdministrator: new FormControl(false, { nonNullable: true }), updatePassword: new FormControl(false, { nonNullable: true }), password: new FormControl('', { validators: [Validators.required] }) @@ -112,8 +113,9 @@ export class AdministrationPageComponent implements OnInit { if (this.userSelectedForEditing) { this.editUserForm.setValue({ - userName: this.userSelectedForEditing.name, - eMail: this.userSelectedForEditing.eMail, + userName: this.userSelectedForEditing.userName, + fullName: this.userSelectedForEditing.fullName ?? '', + eMail: this.userSelectedForEditing.eMail ?? '', isAdministrator: this.userSelectedForEditing.isAdministrator, updatePassword: false, password: '' @@ -145,6 +147,7 @@ export class AdministrationPageComponent implements OnInit { const formValue = this.editUserForm.getRawValue(); const request: UpdateUserEndpointRequest = { userName: formValue.userName, + fullName: (formValue.fullName ?? '').length > 0 ? formValue.fullName : null, eMail: formValue.eMail, isAdministrator: formValue.isAdministrator, updatePassword: formValue.updatePassword, From 385ce9864b18b64737a8644b86b793e36baee49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 21:08:26 +0200 Subject: [PATCH 05/23] Remove user data form + adapt frontend for new logic --- .../Client/src/app/core/models/identity.ts | 5 +- .../core/services/authentication.service.ts | 64 ++++++------- src/Turnierplan.App/Client/src/app/i18n/de.ts | 21 ++--- .../src/app/identity/identity.routes.ts | 5 - .../change-password.component.ts | 2 +- .../change-user-info.component.html | 35 ------- .../change-user-info.component.ts | 91 ------------------- .../administration-page.component.html | 18 +++- .../administration-page.component.ts | 4 +- .../create-user/create-user.component.html | 2 - .../create-user/create-user.component.ts | 13 ++- .../src/app/portal/portal.component.html | 22 ++++- .../Client/src/app/portal/portal.component.ts | 7 +- .../Identity/ChangePasswordEndpoint.cs | 9 +- .../Identity/UpdateUserDataEndpoint.cs | 78 ---------------- .../Endpoints/Users/CreateUserEndpoint.cs | 4 +- .../Endpoints/Users/UpdateUserEndpoint.cs | 4 +- src/Turnierplan.App/Security/ClaimTypes.cs | 6 +- 18 files changed, 98 insertions(+), 292 deletions(-) delete mode 100644 src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.html delete mode 100644 src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.ts delete mode 100644 src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs diff --git a/src/Turnierplan.App/Client/src/app/core/models/identity.ts b/src/Turnierplan.App/Client/src/app/core/models/identity.ts index aff44a47..d5cd8cb0 100644 --- a/src/Turnierplan.App/Client/src/app/core/models/identity.ts +++ b/src/Turnierplan.App/Client/src/app/core/models/identity.ts @@ -1,5 +1,6 @@ export interface AuthenticatedUser { id: string; - displayName: string; - emailAddress: string; + userName: string; + fullName?: string; + emailAddress?: string; } diff --git a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts index f8402017..a2da803b 100644 --- a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts +++ b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts @@ -8,14 +8,14 @@ import { TurnierplanApi } from '../../api/turnierplan-api'; import { login } from '../../api/fn/identity/login'; import { NullableOfChangePasswordFailedReason } from '../../api/models/nullable-of-change-password-failed-reason'; import { changePassword } from '../../api/fn/identity/change-password'; -import { updateUserData } from '../../api/fn/identity/update-user-data'; import { refresh } from '../../api/fn/identity/refresh'; import { logout } from '../../api/fn/identity/logout'; interface TurnierplanAccessToken { exp: number; mail: string; - name: string; + userName: string; + fullName: string; adm?: string; uid: string; } @@ -29,6 +29,7 @@ interface TurnierplanRefreshToken { export class AuthenticationService implements OnDestroy { private static readonly localStorageUserIdKey = 'tp_id_userId'; private static readonly localStorageUserNameKey = 'tp_id_userName'; + private static readonly localStorageUserFullNameKey = 'tp_id_userFullName'; private static readonly localStorageUserEMailKey = 'tp_id_userEMail'; private static readonly localStorageUserAdministratorKey = 'tp_id_userAdmin'; private static readonly localStorageAccessTokenExpiryKey = 'tp_id_accTokenExp'; @@ -50,10 +51,16 @@ export class AuthenticationService implements OnDestroy { ) { const storedUserId = this.readUserIdFromLocalStorage(); const storedUserName = this.readUserNameFromLocalStorage(); + const storedUserFullName = this.readUserFullNameFromLocalStorage(); const storedUserEMail = this.readUserEMailFromLocalStorage(); - if (storedUserId && storedUserName && storedUserEMail) { - this.authentication$.next({ id: storedUserId, displayName: storedUserName, emailAddress: storedUserEMail }); + if (storedUserId && storedUserName) { + this.authentication$.next({ + id: storedUserId, + userName: storedUserName, + fullName: storedUserFullName, + emailAddress: storedUserEMail + }); } } @@ -72,7 +79,8 @@ export class AuthenticationService implements OnDestroy { this.updateLocalStorageCache( decodedAccessToken.uid, - decodedAccessToken.name, + decodedAccessToken.userName, + decodedAccessToken.fullName, decodedAccessToken.mail, decodedAccessToken.adm === 'true', decodedAccessToken.exp, @@ -81,7 +89,8 @@ export class AuthenticationService implements OnDestroy { this.authentication$.next({ id: decodedAccessToken.uid, - displayName: decodedAccessToken.name, + userName: decodedAccessToken.userName, + fullName: decodedAccessToken.fullName, emailAddress: decodedAccessToken.mail }); @@ -97,10 +106,6 @@ export class AuthenticationService implements OnDestroy { this.logoutAndClearData(() => window.location.assign('/')).subscribe(); } - public openEditUserInfoForm(): void { - void this.router.navigate(['portal/user-info'], { queryParams: { redirect_to: this.router.url } }); - } - public openChangePasswordForm(): void { void this.router.navigate(['portal/change-password'], { queryParams: { redirect_to: this.router.url } }); } @@ -132,14 +137,14 @@ export class AuthenticationService implements OnDestroy { } public changePassword( - userEmail: string, + userName: string, newPassword: string, currentPassword: string ): Observable<'success' | 'failure' | NullableOfChangePasswordFailedReason> { return this.turnierplanApi .invoke(changePassword, { body: { - eMail: userEmail, + userName: userName, newPassword: newPassword, currentPassword: currentPassword } @@ -163,22 +168,6 @@ export class AuthenticationService implements OnDestroy { ); } - public changeUserInformation(userName: string, emailAddress: string): Observable<'success' | 'emailVerificationPending' | 'failure'> { - return this.turnierplanApi.invoke(updateUserData, { body: { userName: userName, eMail: emailAddress } }).pipe( - catchError(() => of(undefined)), - map((result) => { - if (result?.success !== true) { - return 'failure'; - } - - this.updateLocalStorageCacheUserInformationOnly(userName, emailAddress); - this.authentication$.next({ id: this.readUserIdFromLocalStorage()!, displayName: userName, emailAddress: emailAddress }); - - return 'success'; - }) - ); - } - private ensureAccessTokenUnprotected(): Observable { const accessTokenExpiry = this.readAccessTokenExpiryFromLocalStorage(); @@ -212,7 +201,8 @@ export class AuthenticationService implements OnDestroy { this.updateLocalStorageCache( decodedAccessToken.uid, - decodedAccessToken.name, + decodedAccessToken.userName, + decodedAccessToken.fullName, decodedAccessToken.mail, decodedAccessToken.adm === 'true', decodedAccessToken.exp, @@ -221,7 +211,8 @@ export class AuthenticationService implements OnDestroy { this.authentication$.next({ id: decodedAccessToken.uid, - displayName: decodedAccessToken.name, + userName: decodedAccessToken.userName, + fullName: decodedAccessToken.fullName, emailAddress: decodedAccessToken.mail }); @@ -269,6 +260,10 @@ export class AuthenticationService implements OnDestroy { return localStorage.getItem(AuthenticationService.localStorageUserNameKey) ?? undefined; } + private readUserFullNameFromLocalStorage(): string | undefined { + return localStorage.getItem(AuthenticationService.localStorageUserFullNameKey) ?? undefined; + } + private readUserEMailFromLocalStorage(): string | undefined { return localStorage.getItem(AuthenticationService.localStorageUserEMailKey) ?? undefined; } @@ -292,23 +287,24 @@ export class AuthenticationService implements OnDestroy { private updateLocalStorageCache( userId: string, userName: string, - userEMail: string, + userFullName: string | undefined, + userEMail: string | undefined, userIsAdmin: boolean, accessTokenExpiry: number, refreshTokenExpiry: number ): void { localStorage.setItem(AuthenticationService.localStorageUserIdKey, userId); localStorage.setItem(AuthenticationService.localStorageUserNameKey, userName); - localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail); + localStorage.setItem(AuthenticationService.localStorageUserFullNameKey, userFullName ?? ''); + localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail ?? ''); localStorage.setItem(AuthenticationService.localStorageUserAdministratorKey, `${userIsAdmin}`); localStorage.setItem(AuthenticationService.localStorageAccessTokenExpiryKey, `${accessTokenExpiry}`); localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`); } - private updateLocalStorageCacheUserInformationOnly(userName: string, userEMail: string): void { + private updateLocalStorageCacheUserNameOnly(userName: string): void { localStorage.setItem(AuthenticationService.localStorageUserNameKey, userName); - localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail); } private updateLocalStorageCacheRefreshTokenExpiryOnly(refreshTokenExpiry: number): void { diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 588c4605..e3150ee6 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -47,11 +47,6 @@ export const de = { Title: 'Nutzerdaten aktualisiert', Message: 'Ihr Benutzerinformationen wurden gespeichert.' }, - EmailVerificationPendingToast: { - Title: 'E-Mail muss bestätigt werden', - Message: - 'Ihr Benutzerinformationen wurden gespeichert. Klicken Sie auf den Link in der Mail, welche wir Ihnen geschickt haben, um die neue E-Mail zu bestätigen.' - }, Back: 'Abbrechen', Submit: 'Speichern' }, @@ -85,7 +80,6 @@ export const de = { UserInfoPopover: { Text: 'Sie sind angemeldet als:\n{{userName}}', Administration: 'Administration', - EditUserInfo: 'Benutzerinformation', ChangePassword: 'Passwort ändern', Logout: 'Abmelden' }, @@ -117,8 +111,8 @@ export const de = { NewUser: 'Neuer Benutzer', Users: { TableLabel: 'Benutzer in dieser turnierplan.NET-Instanz', - Id: 'ID', - Name: 'Name', + UserName: 'Login-ID', + FullName: 'Name', EMail: 'E-Mail', CreatedAt: 'Erstellt am', LastPasswordChange: 'Letzte Passwortänderung', @@ -127,8 +121,9 @@ export const de = { EditUser: { Title: 'Benutzer bearbeiten', Info: 'Ändern Sie die Informationen eines bestehenden Benutzers. Beachten Sie, dass der betroffene Nutzer diese Änderungen unter Umständen nicht direkt sieht.', - Name: 'Name:', - NameInvalid: 'Der Name eines neuen Nutzers darf nicht leer sein.', + UserName: 'Login-ID:', + UserNameInvalid: 'Die Login-ID eines Nutzers darf nicht leer sein.', + FullName: 'Name:', Email: 'E-Mailadresse', EmailInvalid: 'Die eingegebene E-Mailadresse ist ungültig.', IsAdministrator: 'Administrator', @@ -155,11 +150,11 @@ export const de = { Title: 'Neuen Benutzer', LongTitle: 'Neuen Benutzer erstellen', Form: { - UserName: 'Name', + UserName: 'Login-ID:', UserNameInvalid: 'Der Name eines neuen Nutzers darf nicht leer sein.', - Email: 'E-Mailadresse', + Email: 'E-Mailadresse:', EmailInvalid: 'Die eingegebene E-Mailadresse ist ungültig.', - Password: 'Passwort', + Password: 'Passwort:', PasswordInvalid: 'Das eingegebene Passwort ist ungültig.' }, UserNotice: 'Der erstellte Nutzer kann sich unmittelbar danach mit E-Mail und Passwort anmelden.', diff --git a/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts b/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts index 09d657fa..6634c8da 100644 --- a/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts +++ b/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts @@ -2,7 +2,6 @@ import { Routes } from '@angular/router'; import { IdentityComponent } from './identity.component'; import { ChangePasswordComponent } from './pages/change-password/change-password.component'; import { LoginComponent } from './pages/login/login.component'; -import { ChangeUserInfoComponent } from './pages/change-user-info/change-user-info.component'; export const identityRoutes: Routes = [ { @@ -16,10 +15,6 @@ export const identityRoutes: Routes = [ { path: 'login', component: LoginComponent - }, - { - path: 'user-info', - component: ChangeUserInfoComponent } ] } diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts b/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts index 6c3e890d..d7838c70 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts +++ b/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts @@ -82,7 +82,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { .pipe( take(1), switchMap((user) => { - return this.authenticationService.changePassword(user.emailAddress, this.newPassword, this.oldPassword); + return this.authenticationService.changePassword(user.userName, this.newPassword, this.oldPassword); }) ) .subscribe((result) => { diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.html b/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.html deleted file mode 100644 index 74066c29..00000000 --- a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.html +++ /dev/null @@ -1,35 +0,0 @@ -
- -
- - -
- -
- - -
- -@if (emailAddress !== previousEmailAddress) { -
- - -
-} - -
- -
- @if (isLoading) { - - } - -
- -@if (!isLoading && isRequestFailed) { -
-} diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.ts b/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.ts deleted file mode 100644 index 2cc71ff7..00000000 --- a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Subject, take, takeUntil } from 'rxjs'; - -import { AuthenticationService } from '../../../core/services/authentication.service'; -import { NotificationService } from '../../../core/services/notification.service'; -import { TranslateDirective } from '@ngx-translate/core'; -import { FormsModule } from '@angular/forms'; -import { SmallSpinnerComponent } from '../../../core/components/small-spinner/small-spinner.component'; -import { NgClass } from '@angular/common'; - -@Component({ - templateUrl: './change-user-info.component.html', - imports: [TranslateDirective, FormsModule, SmallSpinnerComponent, NgClass] -}) -export class ChangeUserInfoComponent implements OnInit, OnDestroy { - protected previousEmailAddress: string = ''; - - protected userName: string = ''; - protected emailAddress: string = ''; - - protected isLoading = false; - protected isRequestFailed = false; - protected readonly history = history; - - private redirectTarget = '/portal'; - private readonly destroyed$ = new Subject(); - - constructor( - private readonly authenticationService: AuthenticationService, - private readonly notificationService: NotificationService, - private readonly router: Router, - private readonly route: ActivatedRoute - ) {} - - public ngOnInit(): void { - this.route.queryParamMap.pipe(takeUntil(this.destroyed$)).subscribe((params) => { - this.redirectTarget = params.get('redirect_to') ?? '/portal'; - }); - - this.authenticationService.authentication$.pipe(take(1)).subscribe((result) => { - this.userName = result.displayName; - this.previousEmailAddress = result.emailAddress; - this.emailAddress = result.emailAddress; - }); - } - - public ngOnDestroy(): void { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - protected submit(): void { - if (this.isLoading) { - return; - } - - this.isLoading = true; - - this.authenticationService.changeUserInformation(this.userName, this.emailAddress).subscribe({ - next: (result) => { - switch (result) { - case 'success': - this.notificationService.showNotification( - 'success', - 'Identity.ChangeUserInfo.SuccessToast.Title', - 'Identity.ChangeUserInfo.SuccessToast.Message' - ); - - break; - case 'emailVerificationPending': - this.notificationService.showNotification( - 'success', - 'Identity.ChangeUserInfo.EmailVerificationPendingToast.Title', - 'Identity.ChangeUserInfo.EmailVerificationPendingToast.Message', - 60000 - ); - - break; - case 'failure': - this.isRequestFailed = true; - this.isLoading = false; - - return; - } - - void this.router.navigate([this.redirectTarget]); - } - }); - } -} diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html index 00a636fb..9dee1996 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html @@ -16,7 +16,6 @@ - @@ -30,7 +29,6 @@ @for (user of users; track user) { @let isCurrentUser = user.id === currentUserId; - @@ -76,14 +74,26 @@
@let userNameControl = editUserForm.get('userName')!; - + -
+
+
+ +
+ @let fullNameControl = editUserForm.get('fullName')!; + + +
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 4e5c4792..42601b3b 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 @@ -147,8 +147,8 @@ export class AdministrationPageComponent implements OnInit { const formValue = this.editUserForm.getRawValue(); const request: UpdateUserEndpointRequest = { userName: formValue.userName, - fullName: (formValue.fullName ?? '').length > 0 ? formValue.fullName : null, - eMail: formValue.eMail, + fullName: (formValue.fullName ?? '').trim().length > 0 ? formValue.fullName : null, + eMail: (formValue.eMail ?? '').trim().length > 0 ? formValue.eMail : null, isAdministrator: formValue.isAdministrator, updatePassword: formValue.updatePassword, password: formValue.updatePassword ? formValue.password : undefined diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html index 8022ab23..0d2152cf 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html @@ -20,8 +20,6 @@ type="email" class="form-control" formControlName="eMail" - [required]="true" - [minlength]="1" [ngClass]="eMailControl.dirty || eMailControl.touched ? (eMailControl.invalid ? 'is-invalid' : 'is-valid') : ''" />
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts index 253774bc..20055a6c 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts @@ -11,6 +11,7 @@ import { NgClass } from '@angular/common'; import { ActionButtonComponent } from '../../components/action-button/action-button.component'; import { TurnierplanApi } from '../../../api/turnierplan-api'; import { createUser } from '../../../api/fn/users/create-user'; +import { CreateUserEndpointRequest } from '../../../api/models/create-user-endpoint-request'; @Component({ templateUrl: './create-user.component.html', @@ -30,7 +31,7 @@ export class CreateUserComponent implements OnInit { protected form = new FormGroup({ userName: new FormControl('', { nonNullable: true, validators: [Validators.required] }), - eMail: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), + eMail: new FormControl('', { nonNullable: false, validators: [Validators.email] }), password: new FormControl('', { nonNullable: true, validators: [Validators.required] }) }); @@ -60,8 +61,16 @@ export class CreateUserComponent implements OnInit { protected confirmButtonClicked(): void { if (this.form.valid && !this.loadingState.isLoading) { this.loadingState = { isLoading: true }; + + const formValue = this.form.getRawValue(); + const body: CreateUserEndpointRequest = { + userName: formValue.userName, + eMail: (formValue.eMail ?? '').trim().length > 0 ? formValue.eMail : null, + password: formValue.password + }; + this.turnierplanApi - .invoke(createUser, { body: this.form.getRawValue() }) + .invoke(createUser, { body: body }) .pipe(switchMap(() => from(this.router.navigate(['../..'], { relativeTo: this.route })))) .subscribe({ next: () => { diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.component.html b/src/Turnierplan.App/Client/src/app/portal/portal.component.html index 6b16a1a2..6263246d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/portal.component.html @@ -8,21 +8,33 @@ @if (currentUser) {
- - + diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.component.ts b/src/Turnierplan.App/Client/src/app/portal/portal.component.ts index 80a069eb..ced6475c 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/portal.component.ts @@ -19,7 +19,7 @@ import { E2eDirective } from '../core/directives/e2e.directive'; echarts.use([BarChart, GridComponent, CanvasRenderer, TooltipComponent]); -type UserInfoAction = 'EditUserInfo' | 'ChangePassword' | 'Logout'; +type UserInfoAction = 'ChangePassword' | 'Logout'; @Component({ templateUrl: './portal.component.html', @@ -83,8 +83,6 @@ export class PortalComponent implements OnInit, OnDestroy { protected getUserInfoActionIcon(action: UserInfoAction): string { switch (action) { - case 'EditUserInfo': - return 'bi-person-vcard'; case 'ChangePassword': return 'bi-key'; case 'Logout': @@ -94,9 +92,6 @@ export class PortalComponent implements OnInit, OnDestroy { protected userInfoActionClicked(action: UserInfoAction): void { switch (action) { - case 'EditUserInfo': - this.authenticationService.openEditUserInfoForm(); - break; case 'ChangePassword': this.authenticationService.openChangePasswordForm(); break; diff --git a/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs index d052f8ba..47546701 100644 --- a/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs @@ -55,7 +55,7 @@ private async Task Handle( }); } - var user = await userRepository.GetByEmailAsync(request.EMail); + var user = await userRepository.GetByUserNameAsync(request.UserName); if (user is null) { @@ -95,7 +95,7 @@ private async Task Handle( public sealed record ChangePasswordEndpointRequest { - public required string EMail { get; init; } + public required string UserName { get; init; } public required string CurrentPassword { get; init; } @@ -124,9 +124,8 @@ private sealed class Validator : AbstractValidator x.EMail) - .NotEmpty() - .EmailAddress(); + RuleFor(x => x.UserName) + .NotEmpty(); RuleFor(x => x.CurrentPassword) .NotEmpty(); diff --git a/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs deleted file mode 100644 index 956fbad5..00000000 --- a/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs +++ /dev/null @@ -1,78 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Turnierplan.App.Extensions; -using Turnierplan.App.Security; -using Turnierplan.Core.User; -using IdentityOptions = Turnierplan.App.Options.IdentityOptions; - -namespace Turnierplan.App.Endpoints.Identity; - -internal sealed class UpdateUserDataEndpoint : IdentityEndpointBase -{ - public UpdateUserDataEndpoint(IOptionsMonitor options, ISigningKeyProvider signingKeyProvider) - : base(options, signingKeyProvider) - { - } - - protected override HttpMethod Method => HttpMethod.Post; - - protected override string Route => "/api/identity/user-data"; - - protected override Delegate Handler => Handle; - - private async Task Handle( - [FromBody] UpdateUserDataEndpointRequest request, - HttpContext context, - IUserRepository userRepository, - CancellationToken cancellationToken) - { - if (!Validator.Instance.ValidateAndGetResult(request, out var result)) - { - return result; - } - - var user = await userRepository.GetByIdAsync(context.GetCurrentUserIdOrThrow()); - - if (user is null) - { - return Results.Unauthorized(); - } - - user.FullName = request.FullName; - - await userRepository.UnitOfWork.SaveChangesAsync(cancellationToken); - - // Give the user a new access token which includes the updated username claim - var accessToken = CreateTokenForUser(user, false); - - AddResponseCookieForToken(context, accessToken, false); - - return Results.Ok(new UpdateUserDataEndpointResponse - { - Success = true - }); - } - - public sealed record UpdateUserDataEndpointRequest - { - public string? FullName { get; init; } - } - - public sealed record UpdateUserDataEndpointResponse - { - public required bool Success { get; init; } - } - - private sealed class Validator : AbstractValidator - { - public static readonly Validator Instance = new(); - - private Validator() - { - RuleFor(x => x.FullName) - .NotEmpty() - .When(x => x.FullName is not null); - } - } -} diff --git a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs index ba1a629c..2e00fb77 100644 --- a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs @@ -43,9 +43,9 @@ private static async Task Handle( } } - var user = new User(request.UserName) + var user = new User(request.UserName.Trim()) { - FullName = request.FullName + FullName = request.FullName?.Trim() }; if (request.EMail is not null) diff --git a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs index f47c38e8..c45c44df 100644 --- a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs @@ -45,8 +45,8 @@ private static async Task Handle( } } - user.UserName = request.UserName; - user.FullName = request.FullName; + user.UserName = request.UserName.Trim(); + user.FullName = request.FullName?.Trim(); user.IsAdministrator = request.IsAdministrator; user.SetEmailAddress(request.EMail); diff --git a/src/Turnierplan.App/Security/ClaimTypes.cs b/src/Turnierplan.App/Security/ClaimTypes.cs index 14ac4bc2..553b6a12 100644 --- a/src/Turnierplan.App/Security/ClaimTypes.cs +++ b/src/Turnierplan.App/Security/ClaimTypes.cs @@ -3,12 +3,12 @@ namespace Turnierplan.App.Security; internal static class ClaimTypes { public const string Administrator = "adm"; - public const string EMailAddress = "mail"; // TODO Check dependent usages - public const string FullName = "fullName"; // TODO Check dependent usages + public const string EMailAddress = "mail"; + public const string FullName = "fullName"; public const string PrincipalKind = "principalkind"; public const string PrincipalId = "principalid"; public const string SecurityStamp = "sst"; public const string TokenType = "typ"; public const string UserId = "uid"; - public const string UserName = "userName"; // TODO Check dependent usages + public const string UserName = "userName"; } From d73c0190f552052d73c6fa0735aa43fc2a8ec99d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 21:21:57 +0200 Subject: [PATCH 06/23] More changes & fixes --- .../app/core/services/authentication.service.ts | 4 ++-- src/Turnierplan.App/Client/src/app/i18n/de.ts | 15 +-------------- .../identity/pages/login/login.component.html | 10 +++++----- .../app/identity/pages/login/login.component.ts | 8 ++++---- .../administration-page.component.html | 17 +++++++++-------- .../administration-page.component.ts | 10 ++++++---- .../Client/src/app/portal/portal.component.html | 6 +++--- .../Endpoints/Identity/LoginEndpoint.cs | 9 ++++----- .../Endpoints/Users/UpdateUserEndpoint.cs | 6 ++++++ .../Extensions/WebApplicationExtensions.cs | 1 + 10 files changed, 41 insertions(+), 45 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts index a2da803b..98006c1e 100644 --- a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts +++ b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts @@ -69,8 +69,8 @@ export class AuthenticationService implements OnDestroy { this.destroyed$.complete(); } - public login(email: string, password: string): Observable<'success' | 'failure'> { - return this.turnierplanApi.invoke(login, { body: { eMail: email, password: password } }).pipe( + public login(userName: string, password: string): Observable<'success' | 'failure'> { + return this.turnierplanApi.invoke(login, { body: { userName: userName, password: password } }).pipe( catchError(() => of(undefined)), map((result) => { if (result && result.success && result.accessToken && result.refreshToken) { diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index e3150ee6..14325744 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -37,22 +37,9 @@ export const de = { Back: 'Abbrechen', Submit: 'Speichern' }, - ChangeUserInfo: { - Title: 'Benutzerprofil', - UserName: 'Nutzername:', - EMail: 'E-Mail Adresse:', - ChangeEmailNotice: 'Wenn Sie Ihre E-Mail Adresse ändern, müssen Sie sich ab sofort mit dieser neuen E-Mail Adresse anmelden.', - RequestFailed: 'Bei der Bearbeitung der Anfrage ist ein Fehler aufgetreten.', - SuccessToast: { - Title: 'Nutzerdaten aktualisiert', - Message: 'Ihr Benutzerinformationen wurden gespeichert.' - }, - Back: 'Abbrechen', - Submit: 'Speichern' - }, Login: { Title: 'Anmelden', - EMail: 'E-Mail Adresse:', + UserName: 'Login-ID:', Password: 'Passwort:', CookieNotice: 'Wenn Sie sich anmelden, wird ihre aktive Sitzung in einem Cookie gespeichert.', Submit: 'Anmelden', diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html index 72a8b3c4..d8ba9227 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html +++ b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html @@ -1,13 +1,13 @@
- +
diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts index 2a04098e..139d249a 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts +++ b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts @@ -14,7 +14,7 @@ import { E2eDirective } from '../../../core/directives/e2e.directive'; imports: [TranslateDirective, FormsModule, SmallSpinnerComponent, NgClass, E2eDirective] }) export class LoginComponent implements OnInit, OnDestroy { - protected email: string = ''; + protected userName: string = ''; protected password: string = ''; protected isLoading = false; @@ -35,7 +35,7 @@ export class LoginComponent implements OnInit, OnDestroy { } else { this.route.queryParamMap.pipe(takeUntil(this.destroyed$)).subscribe((params) => { this.redirectTarget = params.get('redirect_to') ?? '/portal'; - this.email = params.get('email') ?? this.email; + this.userName = params.get('user_name') ?? this.userName; }); } } @@ -46,13 +46,13 @@ export class LoginComponent implements OnInit, OnDestroy { } protected attemptLogin(): void { - if (this.isLoading || this.email.trim().length === 0 || this.password.length === 0) { + if (this.isLoading || this.userName.trim().length === 0 || this.password.length === 0) { return; } this.isLoading = true; - this.authenticationService.login(this.email, this.password).subscribe((result) => { + this.authenticationService.login(this.userName, this.password).subscribe((result) => { switch (result) { case 'success': void this.router.navigate([this.redirectTarget]); diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html index 9dee1996..9d6f29c6 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html @@ -40,19 +40,20 @@ }
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 42601b3b..4b1e5e46 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 @@ -105,10 +105,6 @@ export class AdministrationPageComponent implements OnInit { } protected editButtonClicked(id: string, template: TemplateRef): void { - if (id === this.currentUserId) { - return; - } - this.userSelectedForEditing = this.users.find((x) => x.id === id); if (this.userSelectedForEditing) { @@ -124,6 +120,12 @@ export class AdministrationPageComponent implements OnInit { this.editUserForm.get('password')!.disable(); this.editUserForm.markAsPristine({ onlySelf: false }); + if (id === this.currentUserId) { + this.editUserForm.get('isAdministrator')!.disable(); + } else { + this.editUserForm.get('isAdministrator')!.enable(); + } + this.currentOffcanvas = this.offcanvasService.open(template, { position: 'end' }); } } diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.component.html b/src/Turnierplan.App/Client/src/app/portal/portal.component.html index 6263246d..da0c4c84 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/portal.component.html @@ -1,5 +1,5 @@
-
+ - - + +
diff --git a/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs index 4905f99a..e2fafc75 100644 --- a/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs @@ -40,7 +40,7 @@ private async Task Handle( return result; } - var user = await userRepository.GetByEmailAsync(request.EMail); + var user = await userRepository.GetByUserNameAsync(request.UserName); if (user is null) { @@ -80,7 +80,7 @@ private async Task Handle( public sealed record LoginEndpointRequest { - public required string EMail { get; init; } + public required string UserName { get; init; } public required string Password { get; init; } } @@ -100,9 +100,8 @@ private sealed class Validator : AbstractValidator private Validator() { - RuleFor(x => x.EMail) - .NotEmpty() - .EmailAddress(); + RuleFor(x => x.UserName) + .NotEmpty(); RuleFor(x => x.Password) .NotEmpty(); diff --git a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs index c45c44df..2c373bad 100644 --- a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs @@ -21,6 +21,7 @@ private static async Task Handle( [FromBody] UpdateUserEndpointRequest request, IPasswordHasher passwordHasher, IUserRepository repository, + HttpContext httpContext, CancellationToken cancellationToken) { if (!Validator.Instance.ValidateAndGetResult(request, out var result)) @@ -45,6 +46,11 @@ private static async Task Handle( } } + if (httpContext.GetCurrentUserIdOrThrow() == user.Id && !request.IsAdministrator) + { + return Results.BadRequest("Cannot take away the administrator privilege of the requesting user."); + } + user.UserName = request.UserName.Trim(); user.FullName = request.FullName?.Trim(); user.IsAdministrator = request.IsAdministrator; diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index a2c7b5bf..6b5e0ec6 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -43,6 +43,7 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) var initialUser = new User(initialUserName) { + FullName = "Administrator", IsAdministrator = true }; From 7805748a951498887332fb553a11f2bc9ae82420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 21:27:47 +0200 Subject: [PATCH 07/23] specify full name when creating user --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 1 + .../pages/create-user/create-user.component.html | 11 +++++++++++ .../portal/pages/create-user/create-user.component.ts | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 14325744..bfb3b8a4 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -139,6 +139,7 @@ export const de = { Form: { UserName: 'Login-ID:', UserNameInvalid: 'Der Name eines neuen Nutzers darf nicht leer sein.', + FullName: 'Name:', Email: 'E-Mailadresse:', EmailInvalid: 'Die eingegebene E-Mailadresse ist ungültig.', Password: 'Passwort:', diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html index 0d2152cf..9fd7615d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html @@ -13,6 +13,17 @@
+
+ + +
+
+
0 ? formValue.fullName : null, eMail: (formValue.eMail ?? '').trim().length > 0 ? formValue.eMail : null, password: formValue.password }; From 2d58f2a8af0171f3db5add3e626fbe99e73823b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 22 Sep 2025 21:29:06 +0200 Subject: [PATCH 08/23] remove obsolete todo --- src/Turnierplan.Core/User/User.cs | 2 -- .../EntityConfigurations/UserEntityTypeConfiguration.cs | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index 62e663e9..4d60ed41 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -2,8 +2,6 @@ namespace Turnierplan.Core.User; -// TODO: change default admin user behavior to use userName instead of email - public sealed class User : Entity { public User(string userName) diff --git a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs index ca085ae5..22595f0e 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs @@ -24,8 +24,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.CreatedAt) .IsRequired(); - // TODO: Add migration - builder.Property(x => x.UserName) .IsRequired(); From a54e5810bccecebeec12a85f322aa19cacf9ef24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 23 Sep 2025 20:25:07 +0200 Subject: [PATCH 09/23] Fix e2e tests --- src/Turnierplan.App/Client/cypress/support/e2e.js | 2 +- src/Turnierplan.App/Client/cypress/support/turnierplan.js | 2 +- .../Client/src/app/identity/pages/login/login.component.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Client/cypress/support/e2e.js b/src/Turnierplan.App/Client/cypress/support/e2e.js index 76ef10d8..4491bd7f 100644 --- a/src/Turnierplan.App/Client/cypress/support/e2e.js +++ b/src/Turnierplan.App/Client/cypress/support/e2e.js @@ -11,7 +11,7 @@ Cypress.Commands.add('getx', (id) => { Cypress.Commands.add('login', () => { cy.visit('/portal/login'); - cy.getx(turnierplan.loginPage.emailField).type('admin@example.com'); + cy.getx(turnierplan.loginPage.userNameField).type('admin'); cy.getx(turnierplan.loginPage.passwordField).type('P@ssw0rd'); cy.getx(turnierplan.loginPage.loginButton).click(); }); diff --git a/src/Turnierplan.App/Client/cypress/support/turnierplan.js b/src/Turnierplan.App/Client/cypress/support/turnierplan.js index 83cdf1bf..b381423b 100644 --- a/src/Turnierplan.App/Client/cypress/support/turnierplan.js +++ b/src/Turnierplan.App/Client/cypress/support/turnierplan.js @@ -15,7 +15,7 @@ export const turnierplan = { newOrganizationButton: 'landing-page-new-organization-button' }, loginPage: { - emailField: 'login-page-email-field', + userNameField: 'login-page-user-name-field', loginButton: 'login-page-login-button', passwordField: 'login-page-password-field' }, diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html index d8ba9227..0c04ecad 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html +++ b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html @@ -6,7 +6,7 @@ class="form-control" id="userName" type="text" - [tpE2E]="'login-page-userName-field'" + [tpE2E]="'login-page-user-name-field'" [(ngModel)]="userName" (keyup.enter)="attemptLogin()" />
From 0330fb9f350fae06a0e8e0fa1f5f79f68a2c783d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 23 Sep 2025 20:26:44 +0200 Subject: [PATCH 10/23] dockercompose update --- src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml b/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml index 0de5b9fb..d610b64c 100644 --- a/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml +++ b/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml @@ -1,4 +1,4 @@ -# This docker compose file is used for running the E2E tests during the CI pipeline. +# This docker compose file is used for running the E2E tests during the CI workflow services: turnierplan.e2e.database: @@ -19,7 +19,7 @@ services: - "45001:8080" environment: - Database__ConnectionString=Host=turnierplan.e2e.database;Database=turnierplan;Username=postgres;Password=P@ssw0rd - - Turnierplan__ApplicationUrl=http://localhost:45009 + - Turnierplan__ApplicationUrl=http://localhost:45001 - Turnierplan__InitialUserPassword=P@ssw0rd networks: From da828a585672e8f3816cbcedba1b915746a7e0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 23 Sep 2025 20:34:29 +0200 Subject: [PATCH 11/23] Add option to add rbac assignment by user name --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 2 +- .../rbac-add-assignment.component.ts | 2 +- .../RoleAssignments/CreateRoleAssignmentEndpoint.cs | 10 +++++----- src/Turnierplan.Core/User/IUserRepository.cs | 2 ++ src/Turnierplan.Dal/Repositories/UserRepository.cs | 9 +++++++++ 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index bfb3b8a4..346a85eb 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -1230,7 +1230,7 @@ export const de = { SelectedRole: 'Gewählte Rolle:', SearchPrincipalPlaceholder: { ApiKey: 'ID des API-Schlüssels eingeben', - User: 'E-Mailadresse des Nutzers eingeben' + User: 'Benutzername oder E-Mailadresse des Nutzers eingeben' }, SearchPrincipalButton: 'Suchen & hinzufügen', CreatingRoleAssignment: 'Die Rollenzuweisung wird erstellt', 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 index 7f2f7a3c..de834abb 100644 --- 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 @@ -79,7 +79,7 @@ export class RbacAddAssignmentComponent implements OnDestroy { scopeId: this.targetScopeId, role: this.selectedRole, apiKeyId: this.selectedPrincipalKind === PrincipalKind.ApiKey ? this.searchPrincipalInput.trim() : null, - userEmail: this.selectedPrincipalKind === PrincipalKind.User ? this.searchPrincipalInput.trim() : null + userNameOrEmail: this.selectedPrincipalKind === PrincipalKind.User ? this.searchPrincipalInput.trim() : null }; this.turnierplanApi diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs index bcbd7774..cdcb2f76 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs @@ -126,9 +126,9 @@ private static async Task CreateRoleAssignmentAsync( return apiKey?.AsPrincipal(); } - if (request.UserEmail is not null) + if (request.UserNameOrEmail is not null) { - var user = await userRepository.GetByEmailAsync(request.UserEmail); + var user = await userRepository.GetByUserNameOrEmailAsync(request.UserNameOrEmail); return user?.AsPrincipal(); } @@ -144,7 +144,7 @@ public sealed record CreateRoleAssignmentEndpointRequest public required PublicId? ApiKeyId { get; init; } - public required string? UserEmail { get; init; } + public required string? UserNameOrEmail { get; init; } } private sealed class Validator : AbstractValidator @@ -160,8 +160,8 @@ private Validator() .IsInEnum(); RuleFor(x => x) - .Must(x => x.ApiKeyId is null ^ x.UserEmail is null) - .WithMessage($"Exactly only one of {nameof(CreateRoleAssignmentEndpointRequest.ApiKeyId)} and {nameof(CreateRoleAssignmentEndpointRequest.UserEmail)} must be specified."); + .Must(x => x.ApiKeyId is null ^ x.UserNameOrEmail is null) + .WithMessage($"Exactly only one of {nameof(CreateRoleAssignmentEndpointRequest.ApiKeyId)} and {nameof(CreateRoleAssignmentEndpointRequest.UserNameOrEmail)} must be specified."); } } } diff --git a/src/Turnierplan.Core/User/IUserRepository.cs b/src/Turnierplan.Core/User/IUserRepository.cs index 543ee614..b3a65fee 100644 --- a/src/Turnierplan.Core/User/IUserRepository.cs +++ b/src/Turnierplan.Core/User/IUserRepository.cs @@ -13,4 +13,6 @@ public interface IUserRepository : IRepository Task GetByUserNameAsync(string userName); Task GetByEmailAsync(string email); + + Task GetByUserNameOrEmailAsync(string userNameOrEmail); } diff --git a/src/Turnierplan.Dal/Repositories/UserRepository.cs b/src/Turnierplan.Dal/Repositories/UserRepository.cs index e7fe1e36..3e7e1821 100644 --- a/src/Turnierplan.Dal/Repositories/UserRepository.cs +++ b/src/Turnierplan.Dal/Repositories/UserRepository.cs @@ -36,4 +36,13 @@ public Task> GetAllUsersAsync() .Where(x => x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedEMail)) .FirstOrDefaultAsync(); } + + public Task GetByUserNameOrEmailAsync(string userNameOrEmail) + { + var normalizedEMail = User.NormalizeEmail(userNameOrEmail); + + return DbSet + .Where(x => x.UserName.Equals(userNameOrEmail) || (x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedEMail))) + .FirstOrDefaultAsync(); + } } From ee6d636f5589c692d14fa3822d7b1e63e32b8404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 23 Sep 2025 20:37:00 +0200 Subject: [PATCH 12/23] Language updates + additional TODOs --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 12 ++++++------ .../Endpoints/Users/UpdateUserEndpoint.cs | 2 ++ src/Turnierplan.Core/User/User.cs | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 346a85eb..d499716f 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -39,7 +39,7 @@ export const de = { }, Login: { Title: 'Anmelden', - UserName: 'Login-ID:', + UserName: 'Benutzername:', Password: 'Passwort:', CookieNotice: 'Wenn Sie sich anmelden, wird ihre aktive Sitzung in einem Cookie gespeichert.', Submit: 'Anmelden', @@ -98,7 +98,7 @@ export const de = { NewUser: 'Neuer Benutzer', Users: { TableLabel: 'Benutzer in dieser turnierplan.NET-Instanz', - UserName: 'Login-ID', + UserName: 'Benutzername', FullName: 'Name', EMail: 'E-Mail', CreatedAt: 'Erstellt am', @@ -108,8 +108,8 @@ export const de = { EditUser: { Title: 'Benutzer bearbeiten', Info: 'Ändern Sie die Informationen eines bestehenden Benutzers. Beachten Sie, dass der betroffene Nutzer diese Änderungen unter Umständen nicht direkt sieht.', - UserName: 'Login-ID:', - UserNameInvalid: 'Die Login-ID eines Nutzers darf nicht leer sein.', + UserName: 'Benutzername:', + UserNameInvalid: 'Der Benutzername eines Nutzers darf nicht leer sein.', FullName: 'Name:', Email: 'E-Mailadresse', EmailInvalid: 'Die eingegebene E-Mailadresse ist ungültig.', @@ -137,7 +137,7 @@ export const de = { Title: 'Neuen Benutzer', LongTitle: 'Neuen Benutzer erstellen', Form: { - UserName: 'Login-ID:', + UserName: 'Benutzername:', UserNameInvalid: 'Der Name eines neuen Nutzers darf nicht leer sein.', FullName: 'Name:', Email: 'E-Mailadresse:', @@ -145,7 +145,7 @@ export const de = { Password: 'Passwort:', PasswordInvalid: 'Das eingegebene Passwort ist ungültig.' }, - UserNotice: 'Der erstellte Nutzer kann sich unmittelbar danach mit E-Mail und Passwort anmelden.', + UserNotice: 'Der erstellte Nutzer kann sich unmittelbar danach mit Benutzername und Passwort anmelden.', Submit: 'Erstellen' }, CreateOrganization: { diff --git a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs index 2c373bad..fa950a82 100644 --- a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs @@ -36,6 +36,8 @@ private static async Task Handle( return Results.NotFound(); } + // TODO Add proper check whether user name is already taken (similar to create user endpoint) + if (request.EMail is not null && !Equals(user.NormalizedEMail, User.NormalizeEmail(request.EMail))) { // If the email address ought to be changed, check that no other user uses that email address diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index 4d60ed41..b0d092a8 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -41,7 +41,7 @@ internal User(Guid id, Guid principalId, DateTime createdAt, string userName, st public DateTime CreatedAt { get; } - public string UserName { get; set; } + public string UserName { get; set; } // TODO: Add normalized user name public string? FullName { get; set; } From 8bbed6c8ba71f4c797118e8aa989df76e6630466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Wed, 24 Sep 2025 21:39:25 +0200 Subject: [PATCH 13/23] Add normalized user name --- .../Endpoints/Users/UpdateUserEndpoint.cs | 9 ++++++--- src/Turnierplan.Core/User/User.cs | 18 ++++++++++++++---- .../Repositories/UserRepository.cs | 10 ++++++---- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs index fa950a82..193c32db 100644 --- a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs @@ -36,9 +36,12 @@ private static async Task Handle( return Results.NotFound(); } - // TODO Add proper check whether user name is already taken (similar to create user endpoint) + if (!user.NormalizedUserName.Equals(User.Normalize(request.UserName)) && await repository.GetByUserNameAsync(request.UserName) is not null) + { + return Results.BadRequest("The specified user name is already taken."); + } - if (request.EMail is not null && !Equals(user.NormalizedEMail, User.NormalizeEmail(request.EMail))) + if (request.EMail is not null && !Equals(user.NormalizedEMail, User.Normalize(request.EMail))) { // If the email address ought to be changed, check that no other user uses that email address @@ -53,10 +56,10 @@ private static async Task Handle( return Results.BadRequest("Cannot take away the administrator privilege of the requesting user."); } - user.UserName = request.UserName.Trim(); user.FullName = request.FullName?.Trim(); user.IsAdministrator = request.IsAdministrator; + user.SetUserName(request.UserName.Trim()); user.SetEmailAddress(request.EMail); if (request.UpdatePassword) diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index b0d092a8..5206c56c 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -11,6 +11,7 @@ public User(string userName) PrincipalId = Guid.NewGuid(); CreatedAt = DateTime.UtcNow; UserName = userName; + NormalizedUserName = Normalize(userName); FullName = null; EMail = null; NormalizedEMail = null; @@ -20,12 +21,13 @@ public User(string userName) SecurityStamp = Guid.Empty; } - internal User(Guid id, Guid principalId, DateTime createdAt, string userName, string? fullName, string? eMail, string? normalizedEMail, string passwordHash, bool isAdministrator, DateTime lastPasswordChange, Guid securityStamp) + internal User(Guid id, Guid principalId, DateTime createdAt, string userName, string normalizedUserName, string? fullName, string? eMail, string? normalizedEMail, string passwordHash, bool isAdministrator, DateTime lastPasswordChange, Guid securityStamp) { Id = id; PrincipalId = principalId; CreatedAt = createdAt; UserName = userName; + NormalizedUserName = normalizedUserName; FullName = fullName; EMail = eMail; NormalizedEMail = normalizedEMail; @@ -41,7 +43,9 @@ internal User(Guid id, Guid principalId, DateTime createdAt, string userName, st public DateTime CreatedAt { get; } - public string UserName { get; set; } // TODO: Add normalized user name + public string UserName { get; private set; } + + public string NormalizedUserName { get; private set; } public string? FullName { get; set; } @@ -71,6 +75,12 @@ public void UpdatePassword(string passwordHash) SecurityStamp = Guid.NewGuid(); } + public void SetUserName(string userName) + { + UserName = userName; + NormalizedUserName = Normalize(userName); + } + public void SetEmailAddress(string? newEmail) { if (newEmail is null) @@ -83,11 +93,11 @@ public void SetEmailAddress(string? newEmail) ArgumentException.ThrowIfNullOrWhiteSpace(newEmail); EMail = newEmail.Trim(); - NormalizedEMail = NormalizeEmail(newEmail); + NormalizedEMail = Normalize(newEmail); } SecurityStamp = Guid.NewGuid(); } - public static string NormalizeEmail(string email) => email.Trim().ToUpper(); + public static string Normalize(string value) => value.Trim().ToUpper(); } diff --git a/src/Turnierplan.Dal/Repositories/UserRepository.cs b/src/Turnierplan.Dal/Repositories/UserRepository.cs index 3e7e1821..5d2f27f8 100644 --- a/src/Turnierplan.Dal/Repositories/UserRepository.cs +++ b/src/Turnierplan.Dal/Repositories/UserRepository.cs @@ -23,14 +23,16 @@ public Task> GetAllUsersAsync() public Task GetByUserNameAsync(string userName) { + var normalizedUserName = User.Normalize(userName); + return DbSet - .Where(x => x.UserName.Equals(userName)) + .Where(x => x.NormalizedUserName.Equals(normalizedUserName)) .FirstOrDefaultAsync(); } public Task GetByEmailAsync(string email) { - var normalizedEMail = User.NormalizeEmail(email); + var normalizedEMail = User.Normalize(email); return DbSet .Where(x => x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedEMail)) @@ -39,10 +41,10 @@ public Task> GetAllUsersAsync() public Task GetByUserNameOrEmailAsync(string userNameOrEmail) { - var normalizedEMail = User.NormalizeEmail(userNameOrEmail); + var normalizedUserNameOrEmail = User.Normalize(userNameOrEmail); return DbSet - .Where(x => x.UserName.Equals(userNameOrEmail) || (x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedEMail))) + .Where(x => x.NormalizedUserName.Equals(normalizedUserNameOrEmail) || (x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedUserNameOrEmail))) .FirstOrDefaultAsync(); } } From fcd40ee9a2b65727aa7a467bb3d8a5b0341294fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Wed, 24 Sep 2025 22:03:02 +0200 Subject: [PATCH 14/23] Index on noramlized user name --- .../EntityConfigurations/UserEntityTypeConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs index 22595f0e..ffe49d96 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs @@ -27,7 +27,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UserName) .IsRequired(); - builder.HasIndex(x => x.UserName) + builder.HasIndex(x => x.NormalizedUserName) .IsUnique(); builder.Property(x => x.FullName) From 32b98c46876e3aee833d012673a3225f9aa9c1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Wed, 24 Sep 2025 22:04:31 +0200 Subject: [PATCH 15/23] recreated migration --- ...gner.cs => 20250924200327_Add_UserName.Designer.cs} | 10 +++++++--- ..._Add_UserName.cs => 20250924200327_Add_UserName.cs} | 0 .../Migrations/TurnierplanContextModelSnapshot.cs | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) rename src/Turnierplan.Dal/Migrations/{20250922154958_Add_UserName.Designer.cs => 20250924200327_Add_UserName.Designer.cs} (99%) rename src/Turnierplan.Dal/Migrations/{20250922154958_Add_UserName.cs => 20250924200327_Add_UserName.cs} (100%) diff --git a/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.Designer.cs b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.Designer.cs similarity index 99% rename from src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.Designer.cs rename to src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.Designer.cs index bed1d8a5..79d16abb 100644 --- a/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.Designer.cs +++ b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.Designer.cs @@ -13,7 +13,7 @@ namespace Turnierplan.Dal.Migrations { [DbContext(typeof(TurnierplanContext))] - [Migration("20250922154958_Add_UserName")] + [Migration("20250924200327_Add_UserName")] partial class Add_UserName { /// @@ -900,6 +900,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("NormalizedEMail") .HasColumnType("text"); + b.Property("NormalizedUserName") + .IsRequired() + .HasColumnType("text"); + b.Property("PasswordHash") .IsRequired() .HasColumnType("text"); @@ -919,10 +923,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("NormalizedEMail") .IsUnique(); - b.HasIndex("PrincipalId") + b.HasIndex("NormalizedUserName") .IsUnique(); - b.HasIndex("UserName") + b.HasIndex("PrincipalId") .IsUnique(); b.ToTable("Users", "turnierplan"); diff --git a/src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.cs b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs similarity index 100% rename from src/Turnierplan.Dal/Migrations/20250922154958_Add_UserName.cs rename to src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs diff --git a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs index a61243ec..d9826518 100644 --- a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs +++ b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs @@ -897,6 +897,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("NormalizedEMail") .HasColumnType("text"); + b.Property("NormalizedUserName") + .IsRequired() + .HasColumnType("text"); + b.Property("PasswordHash") .IsRequired() .HasColumnType("text"); @@ -916,10 +920,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("NormalizedEMail") .IsUnique(); - b.HasIndex("PrincipalId") + b.HasIndex("NormalizedUserName") .IsUnique(); - b.HasIndex("UserName") + b.HasIndex("PrincipalId") .IsUnique(); b.ToTable("Users", "turnierplan"); From 2dd4ea193e496eca69c095d6f70a11376ce3d835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Wed, 24 Sep 2025 22:07:20 +0200 Subject: [PATCH 16/23] Updated migration to ensure correct steps are taken --- .../Migrations/20250924200327_Add_UserName.cs | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs index bd43f714..65035a22 100644 --- a/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs +++ b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs @@ -16,11 +16,11 @@ protected override void Up(MigrationBuilder migrationBuilder) // post-migration: Only UserName is required column, FullName and EMail are optional // migration steps: - // 1. Create new required "UserName" column with a placeholder default value - // 2. Update all users and set the "UserName" to the current value of the "EMail" column + // 1. Create new required "UserName" and "NormalizedUserName" columns with a placeholder default value + // 2. Update all users and set the "UserName" to the current value of the "EMail" column & set "NormalizedUserName" to upper-case variant // 3. Rename the previous "Name" column to "FullName" and make that column optional // 4. Make the "EMail" and "NormalizedEMail" columns optional - // 5. Create the unique index on the "UserName" column + // 5. Create the unique index on the "NormalizedUserName" column // step 1: migrationBuilder.AddColumn( @@ -31,9 +31,18 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: "x"); + migrationBuilder.AddColumn( + name: "NormalizedUserName", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "x"); + // step 2: migrationBuilder.Sql(""" UPDATE turnierplan."Users" SET "UserName" = "EMail"; +UPDATE turnierplan."Users" SET "NormalizedUserName" = UPPER("EMail"); """); // step 3: @@ -73,10 +82,10 @@ protected override void Up(MigrationBuilder migrationBuilder) // step 5: migrationBuilder.CreateIndex( - name: "IX_Users_UserName", + name: "IX_Users_NormalizedUserName", schema: "turnierplan", table: "Users", - column: "UserName", + column: "NormalizedUserName", unique: true); } @@ -89,14 +98,14 @@ protected override void Down(MigrationBuilder migrationBuilder) // post-migration: Name & EMail are both required columns // migration steps: - // 1. Delete the unique index on the "UserName" column + // 1. Delete the unique index on the "NormalizedUserName" column // 2. Make the "EMail" and "NormalizedEMail" columns required // 3. Rename the previous "FullName" column to "Name" and make that column required - // 4. Delete the "UserName" column + // 4. Delete the "UserName" and "NormalizedUserName" columns // step 1: migrationBuilder.DropIndex( - name: "IX_Users_UserName", + name: "IX_Users_NormalizedUserName", schema: "turnierplan", table: "Users"); @@ -146,6 +155,11 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "UserName", schema: "turnierplan", table: "Users"); + + migrationBuilder.DropColumn( + name: "NormalizedUserName", + schema: "turnierplan", + table: "Users"); } } } From 723d6b36dd77602078d59f8063975be2ab03988e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 25 Sep 2025 16:41:26 +0200 Subject: [PATCH 17/23] Lower requirements for password --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 2 +- .../Endpoints/Identity/IdentityEndpointBase.cs | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index d499716f..3c92f15c 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -25,7 +25,7 @@ export const de = { EmptyOrExtraWhitespace: 'Geben Sie ein neues Passwort an, welches keine Leerzeichen am Anfang oder Ende hat.', PasswordsDoNotMatch: 'Die beiden Passwörter stimmen nicht überein.', InsecurePassword: - 'Das neue Passwort muss folgende Kriterien erfüllen:
  • min. 10 Zeichen lang
  • min. 1 Großbuchstabe
  • min. 1 Kleinbuchstabe
  • min. 1 Ziffer
  • min. 1 Sonderzeichen
', + 'Das neue Passwort muss folgende Kriterien erfüllen:
  • min. 8 Zeichen lang
  • min. 1 Buchstabe
  • min. 1 Ziffer
  • min. 1 Sonderzeichen
', InvalidCredentials: 'Das aktuelle Passwort ist falsch.', NewPasswordEqualsCurrent: 'Das neue Passwort darf nicht dem bisherigen Passwort entsprechen.', UnexpectedError: 'Bei der Bearbeitung der Anfrage ist ein Fehler aufgetreten.' diff --git a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs index 60648267..e387bf25 100644 --- a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs @@ -95,9 +95,8 @@ void AddCookie(string path) /// Password is considered secure only if all the following conditions are met: ///
    ///
  • Does not start or end with white-space
  • - ///
  • Length is at least 10 characters
  • - ///
  • Contains at least one upper-case ASCII letter
  • - ///
  • Contains at least one lower-case ASCII letter
  • + ///
  • Length is at least 8 characters
  • + ///
  • Contains at least one letter
  • ///
  • Contains at least one digit
  • ///
  • Contains at least one punctuation or symbol character
  • ///
@@ -108,17 +107,16 @@ protected static bool IsPasswordInsecure(string password) password = password.Trim(); - if (lengthBeforeTrim != password.Length || password.Length < 10) + if (lengthBeforeTrim != password.Length || password.Length < 8) { return true; } - var upper = password.Count(char.IsAsciiLetterUpper); - var lower = password.Count(char.IsAsciiLetterLower); + var letter = password.Count(char.IsAsciiLetter); var digits = password.Count(char.IsDigit); var punctuation = password.Count(char.IsPunctuation); var symbols = password.Count(char.IsSymbol); - return upper == 0 || lower == 0 || digits == 0 || (punctuation == 0 && symbols == 0); + return letter == 0 || digits == 0 || (punctuation == 0 && symbols == 0); } } From a0d243c94564e7eccb52e75b99058b4e84c219ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 25 Sep 2025 16:47:22 +0200 Subject: [PATCH 18/23] Move call to Trim() --- src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs | 2 +- src/Turnierplan.Core/User/User.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs index 193c32db..6fe53120 100644 --- a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs @@ -59,7 +59,7 @@ private static async Task Handle( user.FullName = request.FullName?.Trim(); user.IsAdministrator = request.IsAdministrator; - user.SetUserName(request.UserName.Trim()); + user.SetUserName(request.UserName); user.SetEmailAddress(request.EMail); if (request.UpdatePassword) diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index 5206c56c..617cc2f2 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -77,7 +77,7 @@ public void UpdatePassword(string passwordHash) public void SetUserName(string userName) { - UserName = userName; + UserName = userName.Trim(); NormalizedUserName = Normalize(userName); } From 3566caf827a2e0f090930ba3a72721cf26c64f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 25 Sep 2025 16:51:35 +0200 Subject: [PATCH 19/23] Add missing prop --- .../EntityConfigurations/UserEntityTypeConfiguration.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs index ffe49d96..3e8109d1 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs @@ -27,6 +27,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UserName) .IsRequired(); + builder.Property(x => x.NormalizedUserName) + .IsRequired(); + builder.HasIndex(x => x.NormalizedUserName) .IsUnique(); From d84479fa06ee70e93b76f152caa02782d0bed558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 25 Sep 2025 17:38:55 +0200 Subject: [PATCH 20/23] legacy email --- .../Client/src/app/identity/pages/login/login.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts index 139d249a..e8fd338e 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts +++ b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts @@ -35,7 +35,7 @@ export class LoginComponent implements OnInit, OnDestroy { } else { this.route.queryParamMap.pipe(takeUntil(this.destroyed$)).subscribe((params) => { this.redirectTarget = params.get('redirect_to') ?? '/portal'; - this.userName = params.get('user_name') ?? this.userName; + this.userName = params.get('user_name') ?? params.get('email') ?? ''; }); } } From 62bd2e64e2afc9244c95fe599a70123e5f562db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 25 Sep 2025 17:43:51 +0200 Subject: [PATCH 21/23] Fix some logic issues in auth service --- .../src/app/core/services/authentication.service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts index 98006c1e..7d13c78e 100644 --- a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts +++ b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts @@ -178,9 +178,12 @@ export class AuthenticationService implements OnDestroy { } const logoutWithRedirect = (): Observable => { + // Store userName in variable because logoutAndClearData() removes it from local storage + const userName = this.readUserNameFromLocalStorage(); + return this.logoutAndClearData(() => { void this.router.navigate(['/portal/login'], { - queryParams: { redirect_to: this.router.url, email: this.readUserEMailFromLocalStorage() } + queryParams: { redirect_to: this.router.url, user_name : userName } }); }).pipe(map(() => false)); }; @@ -303,10 +306,6 @@ export class AuthenticationService implements OnDestroy { localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`); } - private updateLocalStorageCacheUserNameOnly(userName: string): void { - localStorage.setItem(AuthenticationService.localStorageUserNameKey, userName); - } - private updateLocalStorageCacheRefreshTokenExpiryOnly(refreshTokenExpiry: number): void { localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`); } @@ -315,8 +314,11 @@ export class AuthenticationService implements OnDestroy { const logout$ = this.turnierplanApi.invoke(logout).pipe( catchError(() => of(undefined)), tap(() => { + localStorage.removeItem(AuthenticationService.localStorageUserIdKey); localStorage.removeItem(AuthenticationService.localStorageUserNameKey); + localStorage.removeItem(AuthenticationService.localStorageUserFullNameKey); localStorage.removeItem(AuthenticationService.localStorageUserEMailKey); + localStorage.removeItem(AuthenticationService.localStorageUserAdministratorKey); localStorage.removeItem(AuthenticationService.localStorageAccessTokenExpiryKey); localStorage.removeItem(AuthenticationService.localStorageRefreshTokenExpiryKey); }), From 67c678e9df2189d07ba54d253ee6548d45a37b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 25 Sep 2025 17:45:55 +0200 Subject: [PATCH 22/23] Add array for all --- .../core/services/authentication.service.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts index 7d13c78e..f1589a5f 100644 --- a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts +++ b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts @@ -35,6 +35,16 @@ export class AuthenticationService implements OnDestroy { private static readonly localStorageAccessTokenExpiryKey = 'tp_id_accTokenExp'; private static readonly localStorageRefreshTokenExpiryKey = 'tp_id_rfsTokenExp'; + private static readonly allLocalStorageKeys = [ + AuthenticationService.localStorageUserIdKey, + AuthenticationService.localStorageUserNameKey, + AuthenticationService.localStorageUserFullNameKey, + AuthenticationService.localStorageUserEMailKey, + AuthenticationService.localStorageUserAdministratorKey, + AuthenticationService.localStorageAccessTokenExpiryKey, + AuthenticationService.localStorageRefreshTokenExpiryKey + ] + // Clock skew should be as short as possible, but still long enough that a request to the token // refresh endpoint can complete even in the case of a bad connection or unfavorable conditions. private static readonly tokenExpiryCheckClockSkewSeconds = 10; @@ -313,15 +323,7 @@ export class AuthenticationService implements OnDestroy { private logoutAndClearData(navigateTo?: () => void): Observable { const logout$ = this.turnierplanApi.invoke(logout).pipe( catchError(() => of(undefined)), - tap(() => { - localStorage.removeItem(AuthenticationService.localStorageUserIdKey); - localStorage.removeItem(AuthenticationService.localStorageUserNameKey); - localStorage.removeItem(AuthenticationService.localStorageUserFullNameKey); - localStorage.removeItem(AuthenticationService.localStorageUserEMailKey); - localStorage.removeItem(AuthenticationService.localStorageUserAdministratorKey); - localStorage.removeItem(AuthenticationService.localStorageAccessTokenExpiryKey); - localStorage.removeItem(AuthenticationService.localStorageRefreshTokenExpiryKey); - }), + tap(() => AuthenticationService.allLocalStorageKeys.forEach(key => localStorage.removeItem(key))), map(() => void 0) ); From a9ed9e11d6b3f84c13cbcf0ff6708981ec040fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 25 Sep 2025 17:58:41 +0200 Subject: [PATCH 23/23] semi --- .../Client/src/app/core/services/authentication.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts index f1589a5f..c11affbc 100644 --- a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts +++ b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts @@ -43,7 +43,7 @@ export class AuthenticationService implements OnDestroy { AuthenticationService.localStorageUserAdministratorKey, AuthenticationService.localStorageAccessTokenExpiryKey, AuthenticationService.localStorageRefreshTokenExpiryKey - ] + ]; // Clock skew should be as short as possible, but still long enough that a request to the token // refresh endpoint can complete even in the case of a bad connection or unfavorable conditions. @@ -193,7 +193,7 @@ export class AuthenticationService implements OnDestroy { return this.logoutAndClearData(() => { void this.router.navigate(['/portal/login'], { - queryParams: { redirect_to: this.router.url, user_name : userName } + queryParams: { redirect_to: this.router.url, user_name: userName } }); }).pipe(map(() => false)); }; @@ -323,7 +323,7 @@ export class AuthenticationService implements OnDestroy { private logoutAndClearData(navigateTo?: () => void): Observable { const logout$ = this.turnierplanApi.invoke(logout).pipe( catchError(() => of(undefined)), - tap(() => AuthenticationService.allLocalStorageKeys.forEach(key => localStorage.removeItem(key))), + tap(() => AuthenticationService.allLocalStorageKeys.forEach((key) => localStorage.removeItem(key))), map(() => void 0) );
{{ user.id }} {{ user.userName }} {{ user.fullName ?? '' }} {{ user.eMail ?? '' }}
-
+
- +
+ +