| @@ -26,5 +26,23 @@ namespace Diligent.WebAPI.Business.Helper | |||
| "</div>" + | |||
| "</div>"; | |||
| } | |||
| public static string RenderRegisterPage(string url) | |||
| { | |||
| return "<div style=\"font-family: sans-serif\">" + | |||
| "<div style=\"font-family: sans-serif;text-align: center;\">" + | |||
| "<h2 style=\"color: #017397;\">Welcome to HR Center</h2>" + | |||
| "<p style=\"font-size: 20px\">" + | |||
| "To register, please click on the button below." + | |||
| "</p>" + | |||
| "<a style = \"color: white;text-decoration:none;background-color: #017397;cursor: pointer;font-size: 20px;width: 220px;text-align: center;border-radius: 5px;padding: 5px 15px;height: 25px;\" " + | |||
| $"href=\"{url}\">" + | |||
| " Click here to register" + | |||
| "</a>" + | |||
| "<p style = \"font-size: 12px; margin-top: 25px;\" >" + | |||
| "Please do not reply to this email.This message was sent from a notification-only address that is not monitored." + | |||
| "</p>" + | |||
| "</div>" + | |||
| "</div>"; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| namespace Diligent.WebAPI.Business.Helper | |||
| { | |||
| public static class StringGenerator | |||
| { | |||
| public static string GenerateRandomPassword(PasswordOptions opts = null) | |||
| { | |||
| if (opts == null) opts = new PasswordOptions() | |||
| { | |||
| RequiredLength = 8, | |||
| RequiredUniqueChars = 4, | |||
| RequireDigit = true, | |||
| RequireLowercase = true, | |||
| RequireNonAlphanumeric = true, | |||
| RequireUppercase = true | |||
| }; | |||
| string[] randomChars = new[] { | |||
| "ABCDEFGHJKLMNOPQRSTUVWXYZ", // uppercase | |||
| "abcdefghijkmnopqrstuvwxyz", // lowercase | |||
| "0123456789", // digits | |||
| "!@$?_-" // non-alphanumeric | |||
| }; | |||
| Random rand = new(Environment.TickCount); | |||
| List<char> chars = new List<char>(); | |||
| if (opts.RequireUppercase) | |||
| chars.Insert(rand.Next(0, chars.Count), | |||
| randomChars[0][rand.Next(0, randomChars[0].Length)]); | |||
| if (opts.RequireLowercase) | |||
| chars.Insert(rand.Next(0, chars.Count), | |||
| randomChars[1][rand.Next(0, randomChars[1].Length)]); | |||
| if (opts.RequireDigit) | |||
| chars.Insert(rand.Next(0, chars.Count), | |||
| randomChars[2][rand.Next(0, randomChars[2].Length)]); | |||
| if (opts.RequireNonAlphanumeric) | |||
| chars.Insert(rand.Next(0, chars.Count), | |||
| randomChars[3][rand.Next(0, randomChars[3].Length)]); | |||
| for (int i = chars.Count; i < opts.RequiredLength | |||
| || chars.Distinct().Count() < opts.RequiredUniqueChars; i++) | |||
| { | |||
| string rcs = randomChars[rand.Next(0, randomChars.Length)]; | |||
| chars.Insert(rand.Next(0, chars.Count), | |||
| rcs[rand.Next(0, rcs.Length)]); | |||
| } | |||
| return new string(chars.ToArray()); | |||
| } | |||
| } | |||
| } | |||
| @@ -17,6 +17,10 @@ namespace Diligent.WebAPI.Business.MappingProfiles | |||
| #region Model to DTO | |||
| CreateMap<User, UserResponseDTO>(); | |||
| CreateMap<User, UserDetailsResponseDTO>() | |||
| .ForMember(dest => dest.PhoneNumber, opt => opt.NullSubstitute("User has no phone number saved.")) | |||
| .ForMember(dest => dest.Position, opt => opt.NullSubstitute("Position has not been declared yet.")) | |||
| .ForMember(dest => dest.SocialMedias, opt => opt.NullSubstitute("User takes no part in any social media.")); | |||
| #endregion | |||
| } | |||
| } | |||
| @@ -1,14 +1,16 @@ | |||
| namespace Diligent.WebAPI.Business.Services.Interfaces | |||
| using Diligent.WebAPI.Contracts.DTOs.User; | |||
| namespace Diligent.WebAPI.Business.Services.Interfaces | |||
| { | |||
| public interface IUserService | |||
| { | |||
| Task<IEnumerable<User?>> GetAll(); | |||
| Task<User?> GetById(int id); | |||
| Task<User?> GetByEmail(string email); | |||
| Task CreateUser(CreateUserRequestDto model); | |||
| Task ToggleEnable(User user); | |||
| Task RemoveUser(User user); | |||
| Task<bool> VerifyToken(User user, string token); | |||
| Task<ServiceResponseDTO<object>> SendRegistrationLink(InviteDTO invite); | |||
| } | |||
| } | |||
| @@ -1,8 +1,11 @@ | |||
| using Diligent.WebAPI.Business.Services.Interfaces; | |||
| using Diligent.WebAPI.Business.Settings; | |||
| using Diligent.WebAPI.Contracts.DTOs.User; | |||
| using Diligent.WebAPI.Data; | |||
| using Microsoft.AspNetCore.Identity; | |||
| using Microsoft.AspNetCore.WebUtilities; | |||
| using Microsoft.Extensions.Logging; | |||
| using System.Web; | |||
| namespace Diligent.WebAPI.Business.Services | |||
| { | |||
| @@ -35,6 +38,8 @@ namespace Diligent.WebAPI.Business.Services | |||
| public async Task<User?> GetById(int id) => | |||
| await _userManager.FindByIdAsync(id.ToString()); | |||
| public async Task<User?> GetByEmail(string email) => | |||
| await _userManager.FindByEmailAsync(email); | |||
| public async Task CreateUser(CreateUserRequestDto model) | |||
| { | |||
| @@ -55,5 +60,55 @@ namespace Diligent.WebAPI.Business.Services | |||
| await _databaseContext.SaveChangesAsync(); | |||
| } | |||
| public async Task<ServiceResponseDTO<object>> SendRegistrationLink(InviteDTO invite) | |||
| { | |||
| // check if user exists | |||
| var check = await _userManager.FindByEmailAsync(invite.Email); | |||
| if (check != null) | |||
| return new ServiceResponseDTO<object>() | |||
| { | |||
| IsError = true, | |||
| ErrorMessage = "User already registered." | |||
| }; | |||
| // create template user | |||
| // this user is disabled to log in until confirming invitation | |||
| var user = new User | |||
| { | |||
| UserName = invite.Email, | |||
| Email = invite.Email, | |||
| FirstName = invite.FirstName, | |||
| LastName = invite.LastName, | |||
| IsEnabled = false | |||
| }; | |||
| await _userManager.CreateAsync(user, StringGenerator.GenerateRandomPassword()); | |||
| // generate invitation token for user | |||
| // encoded for URLs | |||
| var token = await _userManager.GeneratePasswordResetTokenAsync(user); | |||
| token = HttpUtility.UrlEncode(token); | |||
| // send link | |||
| await _emailer.SendEmailAndWriteToDbAsync(invite.Email, "Welcome", HTMLHelper.RenderRegisterPage($"{_frontEndSettings.BaseUrl}/register?token={token}&email={invite.Email}"), isHtml: true); | |||
| await _databaseContext.SaveChangesAsync(); | |||
| return new ServiceResponseDTO<object> | |||
| { | |||
| Data = new { Message = "Link has been sent!" } | |||
| }; | |||
| } | |||
| public async Task<bool> VerifyToken(User user, string token) | |||
| { | |||
| // this method is going to be updated | |||
| // curent new password value is static and only used for testing | |||
| // method is not complete and is currently only used to check if valid reset token is sent | |||
| var result = await _userManager.ResetPasswordAsync(user, token, "Nekasifra123!"); | |||
| return result.Succeeded; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,15 @@ | |||
| using System; | |||
| using System.Collections.Generic; | |||
| using System.Linq; | |||
| using System.Text; | |||
| using System.Threading.Tasks; | |||
| namespace Diligent.WebAPI.Contracts.DTOs.User | |||
| { | |||
| public class InviteDTO | |||
| { | |||
| public string Email { get; set; } | |||
| public string FirstName { get; set; } | |||
| public string LastName { get; set; } | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| namespace Diligent.WebAPI.Contracts.DTOs.User | |||
| { | |||
| public class UserDetailsResponseDTO | |||
| { | |||
| public int Id { get; set; } | |||
| public string FirstName { get; set; } | |||
| public string LastName { get; set; } | |||
| public string Email { get; set; } | |||
| public bool IsEnabled { get; set; } | |||
| public string PhoneNumber { get; set; } | |||
| public string Position { get; set; } | |||
| public string SocialMedias { get; set; } | |||
| } | |||
| } | |||
| @@ -13,7 +13,6 @@ namespace Diligent.WebAPI.Contracts.DTOs.User | |||
| public string LastName { get; set; } | |||
| public string Email { get; set; } | |||
| public bool IsEnabled { get; set; } | |||
| //public string CVLink { get; set; } | |||
| //public string Position { get; set; } | |||
| } | |||
| } | |||
| @@ -8,5 +8,5 @@ public class User : IdentityUser<int> | |||
| public string LastName { get; set; } | |||
| public string? PasswordResetToken { get; set; } | |||
| public List<Comment> Comments { get; set; } | |||
| public bool IsEnabled { get; set; } | |||
| public bool? IsEnabled { get; set; } | |||
| } | |||
| @@ -0,0 +1,36 @@ | |||
| using Microsoft.EntityFrameworkCore.Migrations; | |||
| #nullable disable | |||
| namespace Diligent.WebAPI.Data.Migrations | |||
| { | |||
| public partial class DefaultDisabledUser : Migration | |||
| { | |||
| protected override void Up(MigrationBuilder migrationBuilder) | |||
| { | |||
| migrationBuilder.AlterColumn<bool>( | |||
| name: "IsEnabled", | |||
| table: "AspNetUsers", | |||
| type: "bit", | |||
| nullable: true, | |||
| defaultValue: true, | |||
| oldClrType: typeof(bool), | |||
| oldType: "bit", | |||
| oldDefaultValue: true); | |||
| } | |||
| protected override void Down(MigrationBuilder migrationBuilder) | |||
| { | |||
| migrationBuilder.AlterColumn<bool>( | |||
| name: "IsEnabled", | |||
| table: "AspNetUsers", | |||
| type: "bit", | |||
| nullable: false, | |||
| defaultValue: true, | |||
| oldClrType: typeof(bool), | |||
| oldType: "bit", | |||
| oldNullable: true, | |||
| oldDefaultValue: true); | |||
| } | |||
| } | |||
| } | |||
| @@ -553,7 +553,7 @@ namespace Diligent.WebAPI.Data.Migrations | |||
| .IsRequired() | |||
| .HasColumnType("nvarchar(max)"); | |||
| b.Property<bool>("IsEnabled") | |||
| b.Property<bool?>("IsEnabled") | |||
| .ValueGeneratedOnAdd() | |||
| .HasColumnType("bit") | |||
| .HasDefaultValue(true); | |||
| @@ -24,7 +24,6 @@ namespace Diligent.WebAPI.Host.Controllers.V1 | |||
| return Ok(_mapper.Map<IEnumerable<User?>, IEnumerable<UserResponseDTO>>(await _userService.GetAll())); | |||
| } | |||
| //[Authorize] | |||
| [Authorize] | |||
| [HttpPost("toggleEnable/{id}")] | |||
| public async Task<IActionResult> ToggleEnable(int id) | |||
| @@ -57,7 +56,44 @@ namespace Diligent.WebAPI.Host.Controllers.V1 | |||
| return Ok(user.Id); | |||
| } | |||
| //[Authorize] | |||
| [Authorize] | |||
| [HttpGet("{id}")] | |||
| public async Task<IActionResult> GetUser(int id) | |||
| { | |||
| var user = await _userService.GetById(id); | |||
| if (user == null) | |||
| { | |||
| return BadRequest("User not found"); | |||
| } | |||
| return Ok(_mapper.Map<User, UserDetailsResponseDTO>(user)); | |||
| } | |||
| [Authorize] | |||
| [HttpPost("invite")] | |||
| public async Task<IActionResult> InviteUser([FromBody] InviteDTO invite) | |||
| { | |||
| var response = await _userService.SendRegistrationLink(invite); | |||
| if (response.IsError is true) | |||
| return BadRequest(new { message = response.ErrorMessage }); | |||
| return Ok(response.Data); | |||
| } | |||
| [HttpPost("verify-invite")] | |||
| public async Task<IActionResult> VerifyInvite(string email, string token) | |||
| { | |||
| // controller endpoint currently used only for testing | |||
| // user should be enabled to log in after accepting invite and updating his account | |||
| var user = await _userService.GetByEmail(email); | |||
| var result = await _userService.VerifyToken(user, token); | |||
| return Ok(result); | |||
| } | |||
| //[Authorize] | |||
| [HttpPost] | |||
| public async Task<IActionResult> CreateUser([FromBody] CreateUserRequestDto model) | |||
| @@ -12,8 +12,8 @@ | |||
| "SmtpServer": "smtp.mailtrap.io", | |||
| "SmtpPort": 2525, | |||
| "SmtpUseSSL": true, | |||
| "SmtpUsername": "460e3c49f02e37", | |||
| "SmtpPassword": "66443869eaad55", | |||
| "SmtpUsername": "179be7a6fd2f50", | |||
| "SmtpPassword": "63cde15de0d5d7", | |||
| "SmtpFrom": "noreply@hrcenter.net", | |||
| "SmtpFromName": "HRCenter Team" | |||
| }, | |||
| @@ -12,8 +12,8 @@ | |||
| "SmtpServer": "smtp.mailtrap.io", | |||
| "SmtpPort": 2525, | |||
| "SmtpUseSSL": true, | |||
| "SmtpUsername": "460e3c49f02e37", | |||
| "SmtpPassword": "66443869eaad55", | |||
| "SmtpUsername": "179be7a6fd2f50", | |||
| "SmtpPassword": "63cde15de0d5d7", | |||
| "SmtpFrom": "noreply@hrcenter.net", | |||
| "SmtpFromName": "HRCenter Team" | |||
| }, | |||