Add user manager page (#22)

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
2025-04-27 20:49:57 +02:00
parent 033747899b
commit a2e93c9438
63 changed files with 1249 additions and 135 deletions

View File

@@ -1,6 +1,8 @@
using GameIdeas.Shared.Dto;
using GameIdeas.Shared.Constants;
using GameIdeas.Shared.Dto;
using GameIdeas.WebAPI.Exceptions;
using GameIdeas.WebAPI.Services.Users;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace GameIdeas.WebAPI.Controllers;
@@ -8,7 +10,8 @@ namespace GameIdeas.WebAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class UserController(
IUserService userService,
IUserReadService userReadService,
IUserWriteService userWriteService,
ILoggerFactory loggerFactory) : Controller
{
private readonly ILogger<UserController> logger = loggerFactory.CreateLogger<UserController>();
@@ -18,7 +21,7 @@ public class UserController(
{
try
{
return Ok(await userService.Login(model));
return Ok(await userReadService.Login(model));
}
catch (UserInvalidException e)
{
@@ -36,4 +39,82 @@ public class UserController(
return StatusCode(500, e.Message);
}
}
[Authorize(Roles = GlobalConstants.ADMINISTRATOR)]
[HttpGet("Roles")]
public async Task<ActionResult<IEnumerable<RoleDto>>> GetRoles()
{
try
{
return Ok(await userReadService.GetRoles());
}
catch (Exception e)
{
logger.LogError(e, "Internal error while get roles");
return StatusCode(500, e.Message);
}
}
[Authorize(Roles = GlobalConstants.ADMINISTRATOR)]
[HttpGet]
public async Task<ActionResult<UserListDto>> GetUsers([FromQuery] UserFilterDto filter)
{
try
{
return Ok(await userReadService.GetUsers(filter));
}
catch (Exception e)
{
logger.LogError(e, "Internal error while get users");
return StatusCode(500, e.Message);
}
}
[Authorize(Roles = GlobalConstants.ADMINISTRATOR)]
[HttpPost("Create")]
public async Task<ActionResult<IdDto>> CreateUser([FromBody] UserDto user)
{
try
{
var id = new IdDto() { Id = await userWriteService.CreateUser(user) };
return Created("/Create", id);
}
catch (Exception e)
{
logger.LogError(e, "Internal error while create user");
return StatusCode(500, e.Message);
}
}
[Authorize(Roles = GlobalConstants.ADMINISTRATOR)]
[HttpPut("Update/{userId}")]
public async Task<ActionResult<IdDto>> UpdateUser(string userId, [FromBody] UserDto user)
{
try
{
var id = new IdDto() { Id = await userWriteService.UpdateUser(userId, user) };
return Created("/Update", id);
}
catch (Exception e)
{
logger.LogError(e, "Internal error while update user");
return StatusCode(500, e.Message);
}
}
[Authorize(Roles = GlobalConstants.ADMINISTRATOR)]
[HttpDelete("Delete/{userId}")]
public async Task<ActionResult<IdDto>> DeleteUser(string userId)
{
try
{
var id = new IdDto() { Id = await userWriteService.DeleteUser(userId) };
return Created("/Delete", id);
}
catch (Exception e)
{
logger.LogError(e, "Internal error while delete user");
return StatusCode(500, e.Message);
}
}
}

View File

@@ -34,7 +34,7 @@
"RequestFailedStatusFormat": "Erreur lors de la réponse, code {0}",
"ErrorFetchCategories": "Erreur lors de la récupération des catégories",
"PlaceholderAdd": "Ajouter un nouveau",
"ErrorCreateGame": "Erreur lors de la Création d'un jeu",
"ErrorCreateGame": "Erreur lors de la création d'un jeu",
"InvalidTitle": "Le titre est incorrect",
"InvalidInterest": "L'interêt est incorrect",
"Unknown": "Inconnu",
@@ -49,5 +49,15 @@
"InvalidToken": "Le token JWT est invalide",
"UserUnauthorized": "Utilisateur non authorisé",
"UserLoginFailed": "Authentification de l'utilisateur échoué",
"UserLogoutFailed": "Déconnection de l'utilisateur échoué"
"UserLogoutFailed": "Déconnection de l'utilisateur échoué",
"Roles": "Rôles",
"ErrorFetchUsers": "Erreur lors de la récupération des utilisateurs",
"ErrorFetchRoles": "Erreur lors de la récupération des rôles",
"MissingField": "Un champs est manquant",
"ErrorCreateUser": "Erreur lors de la création d'un utilisateur",
"ErrorUpdateUser": "Erreur lors de la mise à jour d'un utilisateur",
"ErrorDeleteUser": "Erreur lors de la suppression d'un utilisateur",
"Cancel": "Annuler",
"Confirm": "Confirmer",
"ConfirmDeleteDescription": "Êtes-vous sur de vouloir supprimer cet élément ?"
}

View File

@@ -19,13 +19,13 @@ namespace GameIdeas.WebAPI.Migrations
{
GlobalConstants.ADMINISTRATOR_ID.ToString(),
GlobalConstants.ADMINISTRATOR,
GlobalConstants.ADMINISTRATOR.Normalize(),
GlobalConstants.ADMINISTRATOR_NORMALIZED,
Guid.NewGuid().ToString()
},
{
GlobalConstants.MEMBER_ID.ToString(),
GlobalConstants.MEMBER,
GlobalConstants.MEMBER.Normalize(),
GlobalConstants.MEMBER_NORMALIZED,
Guid.NewGuid().ToString()
}
});

View File

@@ -0,0 +1,20 @@
using AutoMapper;
using GameIdeas.Shared.Dto;
using GameIdeas.Shared.Model;
using Microsoft.AspNetCore.Identity;
namespace GameIdeas.WebAPI.Profiles;
public class UserProfile : Profile
{
public UserProfile()
{
CreateMap<IdentityRole, RoleDto>()
.ForMember(d => d.Id, o => o.MapFrom(s => s.Id))
.ForMember(d => d.Name, o => o.MapFrom(s => s.Name));
CreateMap<User, UserDto>()
.ForMember(d => d.Id, o => o.MapFrom(s => s.Id))
.ForMember(d => d.Username, o => o.MapFrom(s => s.UserName));
}
}

View File

@@ -59,12 +59,24 @@ services.AddAuthentication(options =>
};
});
services.Configure<IdentityOptions>(options =>
{
// Default Password settings.
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 6;
options.Password.RequiredUniqueChars = 1;
});
services.AddAuthorization();
services.AddSingleton<TranslationService>();
services.AddSingleton<Translations>();
services.AddScoped<IUserService, UserService>();
services.AddScoped<IUserReadService, UserReadService>();
services.AddScoped<IUserWriteService, UserWriteService>();
services.AddScoped<IGameReadService, GameReadService>();
services.AddScoped<IGameWriteService, GameWriteService>();
services.AddScoped<ICategoryService, CategoryService>();

View File

@@ -72,7 +72,7 @@ public class GameReadService(GameIdeasContext context, IMapper mapper, ICategory
}
}
private void ApplyFilter(ref IQueryable<Game> query, GameFilterDto filter)
private static void ApplyFilter(ref IQueryable<Game> query, GameFilterDto filter)
{
if (filter.PlatformIds != null)
{

View File

@@ -0,0 +1,10 @@
using GameIdeas.Shared.Dto;
namespace GameIdeas.WebAPI.Services.Users;
public interface IUserReadService
{
Task<TokenDto> Login(UserDto user);
Task<IEnumerable<RoleDto>> GetRoles();
Task<UserListDto> GetUsers(UserFilterDto filter);
}

View File

@@ -1,8 +0,0 @@
using GameIdeas.Shared.Dto;
namespace GameIdeas.WebAPI.Services.Users;
public interface IUserService
{
Task<TokenDto> Login(UserDto user);
}

View File

@@ -0,0 +1,10 @@
using GameIdeas.Shared.Dto;
namespace GameIdeas.WebAPI.Services.Users;
public interface IUserWriteService
{
Task<string> CreateUser(UserDto user);
Task<string> UpdateUser(string userId, UserDto user);
Task<string> DeleteUser(string userId);
}

View File

@@ -0,0 +1,147 @@
using AutoMapper;
using GameIdeas.Resources;
using GameIdeas.Shared.Constants;
using GameIdeas.Shared.Dto;
using GameIdeas.Shared.Model;
using GameIdeas.WebAPI.Context;
using GameIdeas.WebAPI.Exceptions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace GameIdeas.WebAPI.Services.Users;
public class UserReadService(
UserManager<User> userManager,
GameIdeasContext context,
IMapper mapper) : IUserReadService
{
public async Task<IEnumerable<RoleDto>> GetRoles()
{
var roles = await context.Roles
.ToListAsync();
return mapper.Map<IEnumerable<RoleDto>>(roles);
}
public async Task<UserListDto> GetUsers(UserFilterDto filter)
{
var users = await context.Users
.OrderBy(u => u.UserName)
.ToListAsync();
var count = users.Count;
ApplyStaticFilter(ref users, filter);
var usersDto = mapper.Map<IEnumerable<UserDto>>(users);
usersDto = await ApplyRoles(usersDto, filter);
return new UserListDto
{
Users = usersDto,
UsersCount = count
};
}
private async Task<IEnumerable<UserDto>> ApplyRoles(IEnumerable<UserDto> users, UserFilterDto filter)
{
var userRolesQuery = context.UserRoles
.Where(ur => users.Select(u => u.Id).Contains(ur.UserId))
.AsQueryable();
var rolesQuery = context.Roles.AsQueryable();
if (filter.RoleIds != null)
{
userRolesQuery = userRolesQuery
.Where(ur => filter.RoleIds.Contains(ur.RoleId));
rolesQuery = rolesQuery.Where(role => filter.RoleIds.Contains(role.Id));
}
var roles = await rolesQuery.ToListAsync();
var userRoles = await userRolesQuery.ToListAsync();
users = users.Where(user => userRoles.Select(ur => ur.UserId).Contains(user.Id));
foreach (var user in users)
{
var currentRoleId = userRoles.FirstOrDefault(ur => ur.UserId == user.Id)?.RoleId;
if (currentRoleId == null)
{
continue;
}
var currentRole = roles.FirstOrDefault(r => r.Id == currentRoleId);
user.Role = mapper.Map<RoleDto>(currentRole);
}
return users;
}
private static void ApplyStaticFilter(ref List<User> users, UserFilterDto filter)
{
if (!string.IsNullOrWhiteSpace(filter.Name))
{
var keywords = filter.Name?
.Split([' '], StringSplitOptions.RemoveEmptyEntries)
.Select(k => k.Trim())
.ToArray() ?? [];
users = users
.Where(user => keywords.All(
kw => user.UserName?.Contains(kw, StringComparison.OrdinalIgnoreCase) ?? true
))
.OrderBy(user => keywords.Min(kw =>
user.UserName?.IndexOf(kw, StringComparison.OrdinalIgnoreCase)
))
.ThenBy(user => user.UserName?.Length)
.ToList();
}
}
public async Task<TokenDto> Login(UserDto userDto)
{
if (userDto.Username == null || userDto.Password == null)
throw new UserInvalidException(ResourcesKey.UserArgumentsNull);
var user = await userManager.FindByNameAsync(userDto.Username);
if (user == null || !await userManager.CheckPasswordAsync(user, userDto.Password))
{
throw new UserUnauthorizedException(ResourcesKey.UserUnauthorized);
}
List<Claim> authClaims =
[
new Claim(ClaimTypes.Name, user.UserName ?? string.Empty),
new Claim(ClaimTypes.Sid, user.Id),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
authClaims.AddRange((await userManager.GetRolesAsync(user))
.Select(r => new Claim(ClaimTypes.Role, r)));
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY")
?? throw new ArgumentNullException(message: ResourcesKey.InvalidToken, null);
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var token = new JwtSecurityToken(
issuer: Environment.GetEnvironmentVariable("JWT_ISSUER"),
audience: Environment.GetEnvironmentVariable("JWT_AUDIENCE"),
expires: DateTime.Now.AddHours(GlobalConstants.JWT_DURATION_HOUR),
claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
);
return new TokenDto
{
Token = new JwtSecurityTokenHandler().WriteToken(token),
Expiration = token.ValidTo
};
}
}

View File

@@ -1,57 +0,0 @@
using GameIdeas.Resources;
using GameIdeas.Shared.Constants;
using GameIdeas.Shared.Dto;
using GameIdeas.Shared.Model;
using GameIdeas.WebAPI.Exceptions;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace GameIdeas.WebAPI.Services.Users;
public class UserService(UserManager<User> userManager) : IUserService
{
public async Task<TokenDto> Login(UserDto userDto)
{
if (userDto.Username == null || userDto.Password == null)
throw new UserInvalidException(ResourcesKey.UserArgumentsNull);
var user = await userManager.FindByNameAsync(userDto.Username);
if (user == null || !await userManager.CheckPasswordAsync(user, userDto.Password))
{
throw new UserUnauthorizedException(ResourcesKey.UserUnauthorized);
}
List<Claim> authClaims =
[
new Claim(ClaimTypes.Name, user.UserName ?? string.Empty),
new Claim(ClaimTypes.Sid, user.Id),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
authClaims.AddRange((await userManager.GetRolesAsync(user))
.Select(r => new Claim(ClaimTypes.Role, r)));
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY")
?? throw new ArgumentNullException(message: ResourcesKey.InvalidToken, null);
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var token = new JwtSecurityToken(
issuer: Environment.GetEnvironmentVariable("JWT_ISSUER"),
audience: Environment.GetEnvironmentVariable("JWT_AUDIENCE"),
expires: DateTime.Now.AddHours(GlobalConstants.JWT_DURATION_HOUR),
claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
);
return new TokenDto
{
Token = new JwtSecurityTokenHandler().WriteToken(token),
Expiration = token.ValidTo
};
}
}

View File

@@ -0,0 +1,83 @@
using AutoMapper;
using GameIdeas.Resources;
using GameIdeas.Shared.Dto;
using GameIdeas.Shared.Model;
using GameIdeas.WebAPI.Exceptions;
using Microsoft.AspNetCore.Identity;
namespace GameIdeas.WebAPI.Services.Users;
public class UserWriteService(
UserManager<User> userManager) : IUserWriteService
{
public async Task<string> CreateUser(UserDto user)
{
if (user.Username == null ||
user.Password == null ||
user.Role == null)
{
throw new UserInvalidException(ResourcesKey.MissingField);
}
User userToCreate = new() { UserName = user.Username };
var result = await userManager.CreateAsync(userToCreate, user.Password);
if (result.Succeeded)
{
await userManager.AddToRoleAsync(userToCreate, user.Role.Name);
}
else
{
throw new UserInvalidException(string.Join("; ", result.Errors.Select(e => $"{e.Code} {e.Description}")));
}
return userToCreate.Id;
}
public async Task<string> DeleteUser(string userId)
{
if (userId == null)
{
throw new ArgumentNullException(ResourcesKey.MissingField);
}
var user = await userManager.FindByIdAsync(userId)
?? throw new UserInvalidException("User not found");
await userManager.DeleteAsync(user);
return userId;
}
public async Task<string> UpdateUser(string userId, UserDto user)
{
if (userId == null)
{
throw new ArgumentNullException(ResourcesKey.MissingField);
}
var userToUpdate = await userManager.FindByIdAsync(userId)
?? throw new UserInvalidException("User not found");
if (user.Username != null)
{
userToUpdate.UserName = user.Username;
await userManager.UpdateAsync(userToUpdate);
}
if (user.Password != null)
{
await userManager.RemovePasswordAsync(userToUpdate);
await userManager.AddPasswordAsync(userToUpdate, user.Password);
}
if (user.Role != null)
{
var roles = await userManager.GetRolesAsync(userToUpdate);
await userManager.RemoveFromRolesAsync(userToUpdate, roles);
await userManager.AddToRoleAsync(userToUpdate, user.Role.Name);
}
return userToUpdate.Id;
}
}