CreateUser ne comptait rien. Ce qui a ete livre le 12/08 dans manager-app -- compteur « X / 5 » et bouton d'ajout desactive -- est un garde-fou d'interface : un POST direct sur l'API passait toujours. Le controle est desormais fait la ou il est opposable, et rend 422. Cote front, rien a retoucher : le resultat d'invokeAPI est deja lu depuis le correctif du 409 e-mail deja pris, donc le message remontera tel quel. Deux choix, tous deux documentes dans le code : - 5 en dur. Le faire varier par plan serait une colonne sur SubscriptionPlan, qui n'en porte aucune sur les utilisateurs -- donc une migration apres le gel du schema (lot B). Dette V1 assumee. - Le SuperAdmin n'y est pas soumis. C'est la seule porte de service qui reste tant qu'aucun champ ne permet de relever la limite d'un client, et ca s'aligne sur le front, qui ne lui montre deja pas le compteur. L'inscription self-service (OnboardingController) n'est pas concernee : elle cree le premier utilisateur d'une instance neuve. 4 tests : au plafond, sous le plafond, plafond par instance, SuperAdmin exempte. dotnet test 175/175. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
13 KiB
C#
309 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Security.Claims;
|
|
using System.Threading.Tasks;
|
|
using Manager.Services;
|
|
using ManagerService.Data;
|
|
using ManagerService.DTOs;
|
|
using ManagerService.Helpers;
|
|
using ManagerService.Service;
|
|
using ManagerService.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSwag.Annotations;
|
|
|
|
namespace ManagerService.Controllers
|
|
{
|
|
[Authorize(Policy = ManagerService.Service.Security.Policies.InstanceAdmin)]
|
|
[ApiController, Route("api/[controller]")]
|
|
[OpenApiTag("User", Description = "User management")]
|
|
public class UserController : ControllerBase
|
|
{
|
|
private UserDatabaseService _userService;
|
|
private readonly ILogger<UserController> _logger;
|
|
private readonly ProfileLogic _profileLogic;
|
|
private readonly MyInfoMateDbContext _myInfoMateDbContext;
|
|
private readonly IEmailService _emailService;
|
|
private readonly IConfiguration _configuration;
|
|
IHexIdGeneratorService idService = new HexIdGeneratorService();
|
|
|
|
/// <summary>
|
|
/// Plafond d'utilisateurs par instance, tous plans confondus.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Dette assumee pour la V1 : le chiffre est en dur. Le faire varier par plan
|
|
/// demanderait une colonne sur SubscriptionPlan, qui n'en porte aucune sur les
|
|
/// utilisateurs, donc une migration apres le gel du schema (lot B).
|
|
/// Le SuperAdmin n'est pas soumis au plafond : c'est la porte de service interne
|
|
/// tant qu'aucun champ ne permet de relever la limite d'un client.
|
|
/// </remarks>
|
|
private const int MaxUsersPerInstance = 5;
|
|
|
|
public UserController(ILogger<UserController> logger, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, IEmailService emailService, IConfiguration configuration)
|
|
{
|
|
_logger = logger;
|
|
_userService = userService;
|
|
_profileLogic = profileLogic;
|
|
_myInfoMateDbContext = myInfoMateDbContext;
|
|
_emailService = emailService;
|
|
_configuration = configuration;
|
|
}
|
|
|
|
private string? GetCallerInstanceId() =>
|
|
User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
|
|
|
|
private bool IsSuperAdmin() =>
|
|
User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin);
|
|
|
|
private UserRole GetCallerRole()
|
|
{
|
|
if (User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin)) return UserRole.SuperAdmin;
|
|
if (User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.InstanceAdmin)) return UserRole.InstanceAdmin;
|
|
if (User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.ContentEditor)) return UserRole.ContentEditor;
|
|
return UserRole.Viewer;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a list of user
|
|
/// </summary>
|
|
[ProducesResponseType(typeof(List<UserDetailDTO>), 200)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpGet]
|
|
public ObjectResult Get()
|
|
{
|
|
try
|
|
{
|
|
var query = _myInfoMateDbContext.Users.AsQueryable();
|
|
|
|
if (!IsSuperAdmin())
|
|
query = query.Where(u => u.InstanceId == GetCallerInstanceId());
|
|
|
|
return new OkObjectResult(query.ToList().Select(u => u.ToDTO()));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a specific user
|
|
/// </summary>
|
|
/// <param name="id">id user</param>
|
|
[ProducesResponseType(typeof(UserDetailDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpGet("{id}")]
|
|
public ObjectResult GetDetail(string id)
|
|
{
|
|
try
|
|
{
|
|
User user = _myInfoMateDbContext.Users.FirstOrDefault(i => i.Id == id);
|
|
|
|
if (user == null || (!IsSuperAdmin() && user.InstanceId != GetCallerInstanceId()))
|
|
throw new KeyNotFoundException("This user was not found");
|
|
|
|
return new OkObjectResult(user.ToDTO());
|
|
}
|
|
catch (KeyNotFoundException ex)
|
|
{
|
|
return new NotFoundObjectResult(ex.Message) {};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create an user
|
|
/// </summary>
|
|
/// <param name="newUserDTO">New user info</param>
|
|
[ProducesResponseType(typeof(UserDetailDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 400)]
|
|
[ProducesResponseType(typeof(string), 409)]
|
|
[ProducesResponseType(typeof(string), 422)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpPost]
|
|
public async Task<ObjectResult> CreateUser([FromBody] UserDetailDTO newUserDTO)
|
|
{
|
|
try
|
|
{
|
|
if (newUserDTO == null)
|
|
throw new ArgumentNullException("User param is null");
|
|
|
|
if (newUserDTO.instanceId == null)
|
|
throw new ArgumentNullException("InstanceId is null");
|
|
|
|
var requestedRole = newUserDTO.role ?? UserRole.ContentEditor;
|
|
if (requestedRole < GetCallerRole())
|
|
throw new UnauthorizedAccessException("Cannot assign a role higher than your own");
|
|
|
|
User newUser = new User();
|
|
newUser.InstanceId = IsSuperAdmin() ? newUserDTO.instanceId : GetCallerInstanceId();
|
|
|
|
if (!IsSuperAdmin() && _myInfoMateDbContext.Users.Count(u => u.InstanceId == newUser.InstanceId) >= MaxUsersPerInstance)
|
|
return UnprocessableEntity($"This instance has reached its limit of {MaxUsersPerInstance} users");
|
|
newUser.Email = newUserDTO.email;
|
|
newUser.FirstName = newUserDTO.firstName;
|
|
newUser.LastName = newUserDTO.lastName;
|
|
newUser.Role = requestedRole;
|
|
newUser.Token = Guid.NewGuid().ToString();
|
|
newUser.DateCreation = DateTime.Now.ToUniversalTime();
|
|
newUser.Id = idService.GenerateHexId();
|
|
|
|
if (_myInfoMateDbContext.Users.Any(u => u.Email == newUser.Email))
|
|
throw new InvalidOperationException("This Email is already used");
|
|
|
|
// No password provided: invite the user by email instead — they set their own
|
|
// password via a token link (same mechanism as onboarding/forgot-password).
|
|
string inviteRawToken = null;
|
|
if (string.IsNullOrEmpty(newUserDTO.password))
|
|
{
|
|
newUser.Password = _profileLogic.HashPassword(PasswordUtils.GetUniqueKey());
|
|
inviteRawToken = PasswordTokenHelper.GenerateRawToken();
|
|
newUser.PasswordTokenHash = PasswordTokenHelper.Hash(inviteRawToken);
|
|
newUser.PasswordTokenExpiresAt = PasswordTokenHelper.DefaultExpiry();
|
|
}
|
|
else
|
|
{
|
|
newUser.Password = _profileLogic.HashPassword(newUserDTO.password);
|
|
}
|
|
|
|
_myInfoMateDbContext.Add(newUser);
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
if (inviteRawToken != null)
|
|
{
|
|
var managerAppUrl = _configuration["AppUrls:ManagerApp"];
|
|
await _emailService.SendUserInvitationEmailAsync(newUser.Email, $"{managerAppUrl}/set-password?token={inviteRawToken}");
|
|
}
|
|
|
|
return new OkObjectResult(newUser.ToDTO());
|
|
}
|
|
catch (ArgumentNullException ex)
|
|
{
|
|
return new BadRequestObjectResult(ex.Message) {};
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 403 };
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return new ConflictObjectResult(ex.Message) {};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update an user
|
|
/// </summary>
|
|
/// <param name="updatedUser">User to update</param>
|
|
[ProducesResponseType(typeof(UserDetailDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 400)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpPut]
|
|
public ObjectResult UpdateUser([FromBody] UserDetailDTO updatedUser)
|
|
{
|
|
try
|
|
{
|
|
if (updatedUser == null)
|
|
throw new ArgumentNullException("User param is null");
|
|
|
|
User user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.Id == updatedUser.id);
|
|
|
|
if (user == null || (!IsSuperAdmin() && user.InstanceId != GetCallerInstanceId()))
|
|
throw new KeyNotFoundException("User does not exist");
|
|
|
|
if (!IsSuperAdmin() && user.Role < GetCallerRole())
|
|
throw new UnauthorizedAccessException("Cannot modify a user with a higher role than your own");
|
|
|
|
user.FirstName = updatedUser.firstName;
|
|
user.LastName = updatedUser.lastName;
|
|
|
|
if (updatedUser.role.HasValue)
|
|
{
|
|
if (updatedUser.role.Value < GetCallerRole())
|
|
throw new UnauthorizedAccessException("Cannot assign a role higher than your own");
|
|
user.Role = updatedUser.role.Value;
|
|
}
|
|
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
return new OkObjectResult(user.ToDTO());
|
|
}
|
|
catch (ArgumentNullException ex)
|
|
{
|
|
return new BadRequestObjectResult(ex.Message) {};
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 403 };
|
|
}
|
|
catch (KeyNotFoundException ex)
|
|
{
|
|
return new NotFoundObjectResult(ex.Message) {};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete an user
|
|
/// </summary>
|
|
/// <param name="id">Id of user to delete</param>
|
|
[ProducesResponseType(typeof(string), 202)]
|
|
[ProducesResponseType(typeof(string), 400)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpDelete("{id}")]
|
|
public ObjectResult DeleteUser(string id)
|
|
{
|
|
try
|
|
{
|
|
if (id == null)
|
|
throw new ArgumentNullException("User param is null");
|
|
|
|
User user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.Id == id);
|
|
|
|
if (user == null || (!IsSuperAdmin() && user.InstanceId != GetCallerInstanceId()))
|
|
throw new KeyNotFoundException("User does not exist");
|
|
|
|
if (!IsSuperAdmin() && user.Role < GetCallerRole())
|
|
throw new UnauthorizedAccessException("Cannot delete a user with a higher role than your own");
|
|
|
|
_myInfoMateDbContext.Remove(user);
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
return new ObjectResult("The user has been deleted") { StatusCode = 202 };
|
|
}
|
|
catch (ArgumentNullException ex)
|
|
{
|
|
return new BadRequestObjectResult(ex.Message) { };
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 403 };
|
|
}
|
|
catch (KeyNotFoundException ex)
|
|
{
|
|
return new NotFoundObjectResult(ex.Message) { };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
}
|
|
}
|